Sender Group Container: Support context menu for avatars (#5900)
This commit is contained in:
parent
f21ca52337
commit
7874fbbb03
@ -1953,3 +1953,4 @@
|
||||
"ApiMessageActionPaidMessagesRefundedIncoming" = "{user} refunded **{stars}** to you";
|
||||
"NotificationTitleNotSupportedInFrozenAccount" = "Your account is frozen";
|
||||
"NotificationMessageNotSupportedInFrozenAccount" = "This action is not available";
|
||||
"ContextMenuItemMention" = "Mention";
|
||||
|
||||
@ -86,6 +86,7 @@ type OwnProps = {
|
||||
noPersonalPhoto?: boolean;
|
||||
observeIntersection?: ObserveFn;
|
||||
onClick?: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
const Avatar: FC<OwnProps> = ({
|
||||
@ -110,6 +111,7 @@ const Avatar: FC<OwnProps> = ({
|
||||
loopIndefinitely,
|
||||
noPersonalPhoto,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}) => {
|
||||
const { openStoryViewer } = getActions();
|
||||
|
||||
@ -297,6 +299,7 @@ const Avatar: FC<OwnProps> = ({
|
||||
aria-label={typeof content === 'string' ? author : undefined}
|
||||
style={buildStyle(`--_size: ${pxSize}px;`, customColor && `--color-user: ${customColor}`)}
|
||||
onClick={handleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="inner">
|
||||
|
||||
@ -93,6 +93,7 @@ import {
|
||||
selectNotifyDefaults,
|
||||
selectNotifyException,
|
||||
selectNoWebPage,
|
||||
selectPeer,
|
||||
selectPeerPaidMessagesStars,
|
||||
selectPeerStory,
|
||||
selectPerformanceSettingsValue,
|
||||
@ -297,6 +298,7 @@ type StateProps =
|
||||
disallowedGifts?: ApiDisallowedGifts;
|
||||
isAccountFrozen?: boolean;
|
||||
isAppConfigLoaded?: boolean;
|
||||
insertingPeerIdMention?: string;
|
||||
};
|
||||
|
||||
enum MainButtonState {
|
||||
@ -421,6 +423,7 @@ const Composer: FC<OwnProps & StateProps> = ({
|
||||
disallowedGifts,
|
||||
isAccountFrozen,
|
||||
isAppConfigLoaded,
|
||||
insertingPeerIdMention,
|
||||
}) => {
|
||||
const {
|
||||
sendMessage,
|
||||
@ -448,6 +451,7 @@ const Composer: FC<OwnProps & StateProps> = ({
|
||||
setReactionEffect,
|
||||
hideEffectInComposer,
|
||||
updateChatSilentPosting,
|
||||
updateInsertingPeerIdMention,
|
||||
} = getActions();
|
||||
|
||||
const oldLang = useOldLang();
|
||||
@ -751,6 +755,15 @@ const Composer: FC<OwnProps & StateProps> = ({
|
||||
currentUserId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!insertingPeerIdMention) return;
|
||||
const peer = selectPeer(getGlobal(), insertingPeerIdMention);
|
||||
if (peer) {
|
||||
insertMention(peer, true, true);
|
||||
}
|
||||
updateInsertingPeerIdMention({ peerId: undefined });
|
||||
}, [insertingPeerIdMention, insertMention]);
|
||||
|
||||
const {
|
||||
isOpen: isInlineBotTooltipOpen,
|
||||
botId: inlineBotId,
|
||||
@ -2410,6 +2423,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
const isStarsBalanceModalOpen = Boolean(tabState.starsBalanceModal);
|
||||
const isAccountFrozen = selectIsCurrentUserFrozen(global);
|
||||
const isAppConfigLoaded = global.isAppConfigLoaded;
|
||||
const insertingPeerIdMention = tabState.insertingPeerIdMention;
|
||||
|
||||
return {
|
||||
availableReactions: global.reactions.availableReactions,
|
||||
@ -2498,6 +2512,7 @@ export default memo(withGlobal<OwnProps>(
|
||||
disallowedGifts: userFullInfo?.disallowedGifts,
|
||||
isAccountFrozen,
|
||||
isAppConfigLoaded,
|
||||
insertingPeerIdMention,
|
||||
};
|
||||
},
|
||||
)(Composer));
|
||||
|
||||
@ -750,6 +750,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
|
||||
onScrollDownToggle={onScrollDownToggle}
|
||||
onNotchToggle={onNotchToggle}
|
||||
onIntersectPinnedMessage={onIntersectPinnedMessage}
|
||||
canPost={canPost}
|
||||
/>
|
||||
) : (
|
||||
<Loading color="white" backgroundColor="dark" />
|
||||
|
||||
@ -70,6 +70,7 @@ interface OwnProps {
|
||||
onScrollDownToggle: BooleanToVoidFunction;
|
||||
onNotchToggle: AnyToVoidFunction;
|
||||
onIntersectPinnedMessage: OnIntersectPinnedMessage;
|
||||
canPost?: boolean;
|
||||
}
|
||||
|
||||
const UNREAD_DIVIDER_CLASS = 'unread-divider';
|
||||
@ -104,6 +105,7 @@ const MessageListContent: FC<OwnProps> = ({
|
||||
onScrollDownToggle,
|
||||
onNotchToggle,
|
||||
onIntersectPinnedMessage,
|
||||
canPost,
|
||||
}) => {
|
||||
const { openHistoryCalendar } = getActions();
|
||||
|
||||
@ -325,6 +327,7 @@ const MessageListContent: FC<OwnProps> = ({
|
||||
message={lastMessage}
|
||||
withAvatar={withAvatar}
|
||||
appearanceOrder={lastAppearanceOrder}
|
||||
canPost={canPost}
|
||||
>
|
||||
{senderGroupElements}
|
||||
</SenderGroupContainer>
|
||||
|
||||
@ -2,13 +2,13 @@ import type { RefObject } from 'react';
|
||||
import { useEffect, useState } from '../../../../lib/teact/teact';
|
||||
import { getGlobal } from '../../../../global';
|
||||
|
||||
import type { ApiChatMember, ApiUser } from '../../../../api/types';
|
||||
import type { ApiChatMember, ApiPeer, ApiUser } from '../../../../api/types';
|
||||
import type { Signal } from '../../../../util/signals';
|
||||
import { ApiMessageEntityTypes } from '../../../../api/types';
|
||||
|
||||
import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
|
||||
import { getMainUsername, getUserFirstOrLastName } from '../../../../global/helpers';
|
||||
import { filterPeersByQuery } from '../../../../global/helpers/peers';
|
||||
import { getMainUsername } from '../../../../global/helpers';
|
||||
import { filterPeersByQuery, getPeerTitle } from '../../../../global/helpers/peers';
|
||||
import focusEditableElement from '../../../../util/focusEditableElement';
|
||||
import { pickTruthy, unique } from '../../../../util/iteratees';
|
||||
import { getCaretPosition, getHtmlBeforeSelection, setCaretPosition } from '../../../../util/selection';
|
||||
@ -17,6 +17,7 @@ import { prepareForRegExp } from '../helpers/prepareForRegExp';
|
||||
import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
|
||||
import useDerivedSignal from '../../../../hooks/useDerivedSignal';
|
||||
import useFlag from '../../../../hooks/useFlag';
|
||||
import useLang from '../../../../hooks/useLang';
|
||||
import useLastCallback from '../../../../hooks/useLastCallback';
|
||||
|
||||
const THROTTLE = 300;
|
||||
@ -39,6 +40,7 @@ export default function useMentionTooltip(
|
||||
topInlineBotIds?: string[],
|
||||
currentUserId?: string,
|
||||
) {
|
||||
const lang = useLang();
|
||||
const [filteredUsers, setFilteredUsers] = useState<ApiUser[] | undefined>();
|
||||
const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
|
||||
|
||||
@ -95,19 +97,23 @@ export default function useMentionTooltip(
|
||||
setFilteredUsers(Object.values(pickTruthy(usersById, filteredIds)));
|
||||
}, [currentUserId, groupChatMembers, topInlineBotIds, getUsernameTag, getWithInlineBots]);
|
||||
|
||||
const insertMention = useLastCallback((user: ApiUser, forceFocus = false) => {
|
||||
if (!user.usernames && !getUserFirstOrLastName(user)) {
|
||||
const insertMention = useLastCallback((
|
||||
peer: ApiPeer,
|
||||
forceFocus = false,
|
||||
insertAtEnd = false,
|
||||
) => {
|
||||
if (!peer.usernames && !getPeerTitle(lang, peer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainUsername = getMainUsername(user);
|
||||
const userFirstOrLastName = getUserFirstOrLastName(user) || '';
|
||||
const mainUsername = getMainUsername(peer);
|
||||
const userFirstOrLastName = getPeerTitle(lang, peer) || '';
|
||||
const htmlToInsert = mainUsername
|
||||
? `@${mainUsername}`
|
||||
: `<a
|
||||
class="text-entity-link"
|
||||
data-entity-type="${ApiMessageEntityTypes.MentionName}"
|
||||
data-user-id="${user.id}"
|
||||
data-user-id="${peer.id}"
|
||||
contenteditable="false"
|
||||
dir="auto"
|
||||
>${userFirstOrLastName}</a>`;
|
||||
@ -115,7 +121,8 @@ export default function useMentionTooltip(
|
||||
const inputEl = inputRef.current!;
|
||||
const htmlBeforeSelection = getHtmlBeforeSelection(inputEl);
|
||||
const fixedHtmlBeforeSelection = cleanWebkitNewLines(htmlBeforeSelection);
|
||||
const atIndex = fixedHtmlBeforeSelection.lastIndexOf('@');
|
||||
const atIndex = insertAtEnd ? fixedHtmlBeforeSelection.length
|
||||
: fixedHtmlBeforeSelection.lastIndexOf('@');
|
||||
const shiftCaretPosition = (mainUsername ? mainUsername.length + 1 : userFirstOrLastName.length)
|
||||
- (fixedHtmlBeforeSelection.length - atIndex);
|
||||
|
||||
|
||||
@ -32,3 +32,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contextMenu {
|
||||
position: absolute;
|
||||
|
||||
:global(.bubble) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { FC } from '../../../lib/teact/teact';
|
||||
import React, {
|
||||
memo,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
|
||||
@ -10,12 +11,17 @@ import type {
|
||||
ApiPeer,
|
||||
} from '../../../api/types';
|
||||
|
||||
import { MESSAGE_APPEARANCE_DELAY } from '../../../config';
|
||||
import {
|
||||
EDITABLE_INPUT_CSS_SELECTOR,
|
||||
MESSAGE_APPEARANCE_DELAY,
|
||||
} from '../../../config';
|
||||
import {
|
||||
getMainUsername,
|
||||
isAnonymousForwardsChat,
|
||||
isAnonymousOwnMessage,
|
||||
isSystemBot,
|
||||
} from '../../../global/helpers';
|
||||
import { isApiPeerUser } from '../../../global/helpers/peers';
|
||||
import {
|
||||
selectForwardedSender,
|
||||
selectIsChatWithSelf,
|
||||
@ -23,11 +29,15 @@ import {
|
||||
} from '../../../global/selectors';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useLastCallback from '../../../hooks/useLastCallback';
|
||||
import useShowTransition from '../../../hooks/useShowTransition';
|
||||
|
||||
import Avatar from '../../common/Avatar';
|
||||
import Menu from '../../ui/Menu';
|
||||
import MenuItem from '../../ui/MenuItem';
|
||||
|
||||
import styles from './SenderGroupContainer.module.scss';
|
||||
|
||||
@ -38,6 +48,7 @@ type OwnProps =
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
appearanceOrder: number;
|
||||
canPost?: boolean;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
@ -61,12 +72,16 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
|
||||
isChatWithSelf,
|
||||
isRepliesChat,
|
||||
isAnonymousForwards,
|
||||
canPost,
|
||||
}) => {
|
||||
const { openChat } = getActions();
|
||||
const { openChat, updateInsertingPeerIdMention } = getActions();
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { forwardInfo } = message;
|
||||
|
||||
const messageSender = canShowSender ? sender : undefined;
|
||||
const lang = useLang();
|
||||
|
||||
const noAppearanceAnimation = appearanceOrder <= 0;
|
||||
const [isShown, markShown] = useFlag(noAppearanceAnimation);
|
||||
@ -81,8 +96,9 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
|
||||
const shouldPreferOriginSender = forwardInfo
|
||||
&& (isChatWithSelf || isRepliesChat || isAnonymousForwards || !messageSender);
|
||||
const avatarPeer = shouldPreferOriginSender ? originSender : messageSender;
|
||||
const isAvatarPeerUser = avatarPeer && isApiPeerUser(avatarPeer);
|
||||
|
||||
const handleAvatarClick = useLastCallback(() => {
|
||||
const handleOpenChat = useLastCallback(() => {
|
||||
if (!avatarPeer) {
|
||||
return;
|
||||
}
|
||||
@ -90,6 +106,21 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
|
||||
openChat({ id: avatarPeer.id });
|
||||
});
|
||||
|
||||
const handleMention = useLastCallback(() => {
|
||||
if (!avatarPeer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR);
|
||||
if (messageInput) {
|
||||
updateInsertingPeerIdMention({ peerId: avatarPeer.id });
|
||||
}
|
||||
});
|
||||
|
||||
const handleAvatarClick = useLastCallback(() => {
|
||||
handleOpenChat();
|
||||
});
|
||||
|
||||
const {
|
||||
ref: avatarRef,
|
||||
shouldRender,
|
||||
@ -98,6 +129,59 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
|
||||
withShouldRender: true,
|
||||
});
|
||||
|
||||
const {
|
||||
isContextMenuOpen, contextMenuAnchor,
|
||||
handleContextMenu, handleContextMenuClose,
|
||||
handleContextMenuHide,
|
||||
} = useContextMenuHandlers(ref);
|
||||
|
||||
const getTriggerElement = useLastCallback(() => avatarRef.current);
|
||||
const getRootElement = useLastCallback(() => document.querySelector('.Transition_slide-active > .MessageList'));
|
||||
const getMenuElement = useLastCallback(
|
||||
() => ref?.current?.querySelector(`.${styles.contextMenu} .bubble`),
|
||||
);
|
||||
const getLayout = useLastCallback(() => ({ withPortal: true }));
|
||||
|
||||
const canMention = canPost && avatarPeer && (isAvatarPeerUser || Boolean(getMainUsername(avatarPeer)));
|
||||
const shouldRenderContextMenu = Boolean(contextMenuAnchor) && (isAvatarPeerUser || canMention);
|
||||
|
||||
function renderContextMenu() {
|
||||
return (
|
||||
<Menu
|
||||
isOpen={isContextMenuOpen}
|
||||
anchor={contextMenuAnchor}
|
||||
getTriggerElement={getTriggerElement}
|
||||
getRootElement={getRootElement}
|
||||
getLayout={getLayout}
|
||||
getMenuElement={getMenuElement}
|
||||
className={styles.contextMenu}
|
||||
onClose={handleContextMenuClose}
|
||||
onCloseAnimationEnd={handleContextMenuHide}
|
||||
withPortal
|
||||
autoClose
|
||||
>
|
||||
<>
|
||||
{isAvatarPeerUser && (
|
||||
<MenuItem
|
||||
icon="comments"
|
||||
onClick={handleOpenChat}
|
||||
>
|
||||
{lang('SendMessage')}
|
||||
</MenuItem>
|
||||
)}
|
||||
{canMention && (
|
||||
<MenuItem
|
||||
icon="mention"
|
||||
onClick={handleMention}
|
||||
>
|
||||
{lang('ContextMenuItemMention')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAvatar() {
|
||||
const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined;
|
||||
|
||||
@ -108,6 +192,7 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
|
||||
peer={avatarPeer}
|
||||
text={hiddenName}
|
||||
onClick={avatarPeer ? handleAvatarClick : undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -118,13 +203,14 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div id={id} className={className}>
|
||||
<div id={id} className={className} ref={ref}>
|
||||
{shouldRender && (
|
||||
<div ref={avatarRef} className={styles.avatarContainer}>
|
||||
{renderAvatar()}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{shouldRenderContextMenu && renderContextMenu()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -678,6 +678,13 @@ addActionHandler('saveEffectInDraft', (global, actions, payload): ActionReturnTy
|
||||
});
|
||||
});
|
||||
|
||||
addActionHandler('updateInsertingPeerIdMention', (global, actions, payload): ActionReturnType => {
|
||||
const { peerId, tabId = getCurrentTabId() } = payload || {};
|
||||
return updateTabState(global, {
|
||||
insertingPeerIdMention: peerId,
|
||||
}, tabId);
|
||||
});
|
||||
|
||||
async function saveDraft<T extends GlobalState>({
|
||||
global, chatId, threadId, draft, isLocalOnly, noLocalTimeUpdate,
|
||||
} : {
|
||||
|
||||
@ -969,6 +969,9 @@ export interface ActionPayloads {
|
||||
focusLastMessage: WithTabId | undefined;
|
||||
updateDraftReplyInfo: Partial<ApiInputMessageReplyInfo> & WithTabId;
|
||||
resetDraftReplyInfo: WithTabId | undefined;
|
||||
updateInsertingPeerIdMention: {
|
||||
peerId?: string;
|
||||
} & WithTabId;
|
||||
|
||||
// Multitab
|
||||
destroyConnection: undefined;
|
||||
|
||||
@ -799,4 +799,5 @@ export type TabState = {
|
||||
|
||||
isWaitingForStarGiftUpgrade?: true;
|
||||
isWaitingForStarGiftTransfer?: true;
|
||||
insertingPeerIdMention?: string;
|
||||
};
|
||||
|
||||
1
src/types/language.d.ts
vendored
1
src/types/language.d.ts
vendored
@ -1502,6 +1502,7 @@ export interface LangPair {
|
||||
'ActionPaidMessageGroupPriceFree': undefined;
|
||||
'NotificationTitleNotSupportedInFrozenAccount': undefined;
|
||||
'NotificationMessageNotSupportedInFrozenAccount': undefined;
|
||||
'ContextMenuItemMention': undefined;
|
||||
}
|
||||
|
||||
export interface LangPairWithVariables<V extends unknown = LangVariable> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user