Sender Group Container: Support context menu for avatars (#5900)

This commit is contained in:
Alexander Zinchuk 2025-05-14 19:02:19 +03:00
parent f21ca52337
commit 7874fbbb03
12 changed files with 149 additions and 13 deletions

View File

@ -1953,3 +1953,4 @@
"ApiMessageActionPaidMessagesRefundedIncoming" = "{user} refunded **{stars}** to you"; "ApiMessageActionPaidMessagesRefundedIncoming" = "{user} refunded **{stars}** to you";
"NotificationTitleNotSupportedInFrozenAccount" = "Your account is frozen"; "NotificationTitleNotSupportedInFrozenAccount" = "Your account is frozen";
"NotificationMessageNotSupportedInFrozenAccount" = "This action is not available"; "NotificationMessageNotSupportedInFrozenAccount" = "This action is not available";
"ContextMenuItemMention" = "Mention";

View File

@ -86,6 +86,7 @@ type OwnProps = {
noPersonalPhoto?: boolean; noPersonalPhoto?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onClick?: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => void; onClick?: (e: ReactMouseEvent<HTMLDivElement, MouseEvent>, hasMedia: boolean) => void;
onContextMenu?: (e: React.MouseEvent) => void;
}; };
const Avatar: FC<OwnProps> = ({ const Avatar: FC<OwnProps> = ({
@ -110,6 +111,7 @@ const Avatar: FC<OwnProps> = ({
loopIndefinitely, loopIndefinitely,
noPersonalPhoto, noPersonalPhoto,
onClick, onClick,
onContextMenu,
}) => { }) => {
const { openStoryViewer } = getActions(); const { openStoryViewer } = getActions();
@ -297,6 +299,7 @@ const Avatar: FC<OwnProps> = ({
aria-label={typeof content === 'string' ? author : undefined} aria-label={typeof content === 'string' ? author : undefined}
style={buildStyle(`--_size: ${pxSize}px;`, customColor && `--color-user: ${customColor}`)} style={buildStyle(`--_size: ${pxSize}px;`, customColor && `--color-user: ${customColor}`)}
onClick={handleClick} onClick={handleClick}
onContextMenu={onContextMenu}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
<div className="inner"> <div className="inner">

View File

@ -93,6 +93,7 @@ import {
selectNotifyDefaults, selectNotifyDefaults,
selectNotifyException, selectNotifyException,
selectNoWebPage, selectNoWebPage,
selectPeer,
selectPeerPaidMessagesStars, selectPeerPaidMessagesStars,
selectPeerStory, selectPeerStory,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
@ -297,6 +298,7 @@ type StateProps =
disallowedGifts?: ApiDisallowedGifts; disallowedGifts?: ApiDisallowedGifts;
isAccountFrozen?: boolean; isAccountFrozen?: boolean;
isAppConfigLoaded?: boolean; isAppConfigLoaded?: boolean;
insertingPeerIdMention?: string;
}; };
enum MainButtonState { enum MainButtonState {
@ -421,6 +423,7 @@ const Composer: FC<OwnProps & StateProps> = ({
disallowedGifts, disallowedGifts,
isAccountFrozen, isAccountFrozen,
isAppConfigLoaded, isAppConfigLoaded,
insertingPeerIdMention,
}) => { }) => {
const { const {
sendMessage, sendMessage,
@ -448,6 +451,7 @@ const Composer: FC<OwnProps & StateProps> = ({
setReactionEffect, setReactionEffect,
hideEffectInComposer, hideEffectInComposer,
updateChatSilentPosting, updateChatSilentPosting,
updateInsertingPeerIdMention,
} = getActions(); } = getActions();
const oldLang = useOldLang(); const oldLang = useOldLang();
@ -751,6 +755,15 @@ const Composer: FC<OwnProps & StateProps> = ({
currentUserId, currentUserId,
); );
useEffect(() => {
if (!insertingPeerIdMention) return;
const peer = selectPeer(getGlobal(), insertingPeerIdMention);
if (peer) {
insertMention(peer, true, true);
}
updateInsertingPeerIdMention({ peerId: undefined });
}, [insertingPeerIdMention, insertMention]);
const { const {
isOpen: isInlineBotTooltipOpen, isOpen: isInlineBotTooltipOpen,
botId: inlineBotId, botId: inlineBotId,
@ -2410,6 +2423,7 @@ export default memo(withGlobal<OwnProps>(
const isStarsBalanceModalOpen = Boolean(tabState.starsBalanceModal); const isStarsBalanceModalOpen = Boolean(tabState.starsBalanceModal);
const isAccountFrozen = selectIsCurrentUserFrozen(global); const isAccountFrozen = selectIsCurrentUserFrozen(global);
const isAppConfigLoaded = global.isAppConfigLoaded; const isAppConfigLoaded = global.isAppConfigLoaded;
const insertingPeerIdMention = tabState.insertingPeerIdMention;
return { return {
availableReactions: global.reactions.availableReactions, availableReactions: global.reactions.availableReactions,
@ -2498,6 +2512,7 @@ export default memo(withGlobal<OwnProps>(
disallowedGifts: userFullInfo?.disallowedGifts, disallowedGifts: userFullInfo?.disallowedGifts,
isAccountFrozen, isAccountFrozen,
isAppConfigLoaded, isAppConfigLoaded,
insertingPeerIdMention,
}; };
}, },
)(Composer)); )(Composer));

View File

@ -750,6 +750,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
onScrollDownToggle={onScrollDownToggle} onScrollDownToggle={onScrollDownToggle}
onNotchToggle={onNotchToggle} onNotchToggle={onNotchToggle}
onIntersectPinnedMessage={onIntersectPinnedMessage} onIntersectPinnedMessage={onIntersectPinnedMessage}
canPost={canPost}
/> />
) : ( ) : (
<Loading color="white" backgroundColor="dark" /> <Loading color="white" backgroundColor="dark" />

View File

@ -70,6 +70,7 @@ interface OwnProps {
onScrollDownToggle: BooleanToVoidFunction; onScrollDownToggle: BooleanToVoidFunction;
onNotchToggle: AnyToVoidFunction; onNotchToggle: AnyToVoidFunction;
onIntersectPinnedMessage: OnIntersectPinnedMessage; onIntersectPinnedMessage: OnIntersectPinnedMessage;
canPost?: boolean;
} }
const UNREAD_DIVIDER_CLASS = 'unread-divider'; const UNREAD_DIVIDER_CLASS = 'unread-divider';
@ -104,6 +105,7 @@ const MessageListContent: FC<OwnProps> = ({
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
onIntersectPinnedMessage, onIntersectPinnedMessage,
canPost,
}) => { }) => {
const { openHistoryCalendar } = getActions(); const { openHistoryCalendar } = getActions();
@ -325,6 +327,7 @@ const MessageListContent: FC<OwnProps> = ({
message={lastMessage} message={lastMessage}
withAvatar={withAvatar} withAvatar={withAvatar}
appearanceOrder={lastAppearanceOrder} appearanceOrder={lastAppearanceOrder}
canPost={canPost}
> >
{senderGroupElements} {senderGroupElements}
</SenderGroupContainer> </SenderGroupContainer>

View File

@ -2,13 +2,13 @@ import type { RefObject } from 'react';
import { useEffect, useState } from '../../../../lib/teact/teact'; import { useEffect, useState } from '../../../../lib/teact/teact';
import { getGlobal } from '../../../../global'; 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 type { Signal } from '../../../../util/signals';
import { ApiMessageEntityTypes } from '../../../../api/types'; import { ApiMessageEntityTypes } from '../../../../api/types';
import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom'; import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import { getMainUsername, getUserFirstOrLastName } from '../../../../global/helpers'; import { getMainUsername } from '../../../../global/helpers';
import { filterPeersByQuery } from '../../../../global/helpers/peers'; import { filterPeersByQuery, getPeerTitle } from '../../../../global/helpers/peers';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import { pickTruthy, unique } from '../../../../util/iteratees'; import { pickTruthy, unique } from '../../../../util/iteratees';
import { getCaretPosition, getHtmlBeforeSelection, setCaretPosition } from '../../../../util/selection'; import { getCaretPosition, getHtmlBeforeSelection, setCaretPosition } from '../../../../util/selection';
@ -17,6 +17,7 @@ import { prepareForRegExp } from '../helpers/prepareForRegExp';
import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers'; import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
import useDerivedSignal from '../../../../hooks/useDerivedSignal'; import useDerivedSignal from '../../../../hooks/useDerivedSignal';
import useFlag from '../../../../hooks/useFlag'; import useFlag from '../../../../hooks/useFlag';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback'; import useLastCallback from '../../../../hooks/useLastCallback';
const THROTTLE = 300; const THROTTLE = 300;
@ -39,6 +40,7 @@ export default function useMentionTooltip(
topInlineBotIds?: string[], topInlineBotIds?: string[],
currentUserId?: string, currentUserId?: string,
) { ) {
const lang = useLang();
const [filteredUsers, setFilteredUsers] = useState<ApiUser[] | undefined>(); const [filteredUsers, setFilteredUsers] = useState<ApiUser[] | undefined>();
const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false); const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
@ -95,19 +97,23 @@ export default function useMentionTooltip(
setFilteredUsers(Object.values(pickTruthy(usersById, filteredIds))); setFilteredUsers(Object.values(pickTruthy(usersById, filteredIds)));
}, [currentUserId, groupChatMembers, topInlineBotIds, getUsernameTag, getWithInlineBots]); }, [currentUserId, groupChatMembers, topInlineBotIds, getUsernameTag, getWithInlineBots]);
const insertMention = useLastCallback((user: ApiUser, forceFocus = false) => { const insertMention = useLastCallback((
if (!user.usernames && !getUserFirstOrLastName(user)) { peer: ApiPeer,
forceFocus = false,
insertAtEnd = false,
) => {
if (!peer.usernames && !getPeerTitle(lang, peer)) {
return; return;
} }
const mainUsername = getMainUsername(user); const mainUsername = getMainUsername(peer);
const userFirstOrLastName = getUserFirstOrLastName(user) || ''; const userFirstOrLastName = getPeerTitle(lang, peer) || '';
const htmlToInsert = mainUsername const htmlToInsert = mainUsername
? `@${mainUsername}` ? `@${mainUsername}`
: `<a : `<a
class="text-entity-link" class="text-entity-link"
data-entity-type="${ApiMessageEntityTypes.MentionName}" data-entity-type="${ApiMessageEntityTypes.MentionName}"
data-user-id="${user.id}" data-user-id="${peer.id}"
contenteditable="false" contenteditable="false"
dir="auto" dir="auto"
>${userFirstOrLastName}</a>`; >${userFirstOrLastName}</a>`;
@ -115,7 +121,8 @@ export default function useMentionTooltip(
const inputEl = inputRef.current!; const inputEl = inputRef.current!;
const htmlBeforeSelection = getHtmlBeforeSelection(inputEl); const htmlBeforeSelection = getHtmlBeforeSelection(inputEl);
const fixedHtmlBeforeSelection = cleanWebkitNewLines(htmlBeforeSelection); const fixedHtmlBeforeSelection = cleanWebkitNewLines(htmlBeforeSelection);
const atIndex = fixedHtmlBeforeSelection.lastIndexOf('@'); const atIndex = insertAtEnd ? fixedHtmlBeforeSelection.length
: fixedHtmlBeforeSelection.lastIndexOf('@');
const shiftCaretPosition = (mainUsername ? mainUsername.length + 1 : userFirstOrLastName.length) const shiftCaretPosition = (mainUsername ? mainUsername.length + 1 : userFirstOrLastName.length)
- (fixedHtmlBeforeSelection.length - atIndex); - (fixedHtmlBeforeSelection.length - atIndex);

View File

@ -32,3 +32,11 @@
} }
} }
} }
.contextMenu {
position: absolute;
:global(.bubble) {
width: auto;
}
}

View File

@ -2,6 +2,7 @@ import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, memo,
useEffect, useEffect,
useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
@ -10,12 +11,17 @@ import type {
ApiPeer, ApiPeer,
} from '../../../api/types'; } from '../../../api/types';
import { MESSAGE_APPEARANCE_DELAY } from '../../../config';
import { import {
EDITABLE_INPUT_CSS_SELECTOR,
MESSAGE_APPEARANCE_DELAY,
} from '../../../config';
import {
getMainUsername,
isAnonymousForwardsChat, isAnonymousForwardsChat,
isAnonymousOwnMessage, isAnonymousOwnMessage,
isSystemBot, isSystemBot,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { isApiPeerUser } from '../../../global/helpers/peers';
import { import {
selectForwardedSender, selectForwardedSender,
selectIsChatWithSelf, selectIsChatWithSelf,
@ -23,11 +29,15 @@ import {
} from '../../../global/selectors'; } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import Avatar from '../../common/Avatar'; import Avatar from '../../common/Avatar';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import styles from './SenderGroupContainer.module.scss'; import styles from './SenderGroupContainer.module.scss';
@ -38,6 +48,7 @@ type OwnProps =
children: React.ReactNode; children: React.ReactNode;
id: string; id: string;
appearanceOrder: number; appearanceOrder: number;
canPost?: boolean;
}; };
type StateProps = { type StateProps = {
@ -61,12 +72,16 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
isChatWithSelf, isChatWithSelf,
isRepliesChat, isRepliesChat,
isAnonymousForwards, 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 { forwardInfo } = message;
const messageSender = canShowSender ? sender : undefined; const messageSender = canShowSender ? sender : undefined;
const lang = useLang();
const noAppearanceAnimation = appearanceOrder <= 0; const noAppearanceAnimation = appearanceOrder <= 0;
const [isShown, markShown] = useFlag(noAppearanceAnimation); const [isShown, markShown] = useFlag(noAppearanceAnimation);
@ -81,8 +96,9 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
const shouldPreferOriginSender = forwardInfo const shouldPreferOriginSender = forwardInfo
&& (isChatWithSelf || isRepliesChat || isAnonymousForwards || !messageSender); && (isChatWithSelf || isRepliesChat || isAnonymousForwards || !messageSender);
const avatarPeer = shouldPreferOriginSender ? originSender : messageSender; const avatarPeer = shouldPreferOriginSender ? originSender : messageSender;
const isAvatarPeerUser = avatarPeer && isApiPeerUser(avatarPeer);
const handleAvatarClick = useLastCallback(() => { const handleOpenChat = useLastCallback(() => {
if (!avatarPeer) { if (!avatarPeer) {
return; return;
} }
@ -90,6 +106,21 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
openChat({ id: avatarPeer.id }); 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 { const {
ref: avatarRef, ref: avatarRef,
shouldRender, shouldRender,
@ -98,6 +129,59 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
withShouldRender: true, 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() { function renderAvatar() {
const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined; const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined;
@ -108,6 +192,7 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
peer={avatarPeer} peer={avatarPeer}
text={hiddenName} text={hiddenName}
onClick={avatarPeer ? handleAvatarClick : undefined} onClick={avatarPeer ? handleAvatarClick : undefined}
onContextMenu={handleContextMenu}
/> />
); );
} }
@ -118,13 +203,14 @@ const SenderGroupContainer: FC<OwnProps & StateProps> = ({
); );
return ( return (
<div id={id} className={className}> <div id={id} className={className} ref={ref}>
{shouldRender && ( {shouldRender && (
<div ref={avatarRef} className={styles.avatarContainer}> <div ref={avatarRef} className={styles.avatarContainer}>
{renderAvatar()} {renderAvatar()}
</div> </div>
)} )}
{children} {children}
{shouldRenderContextMenu && renderContextMenu()}
</div> </div>
); );
}; };

View File

@ -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>({ async function saveDraft<T extends GlobalState>({
global, chatId, threadId, draft, isLocalOnly, noLocalTimeUpdate, global, chatId, threadId, draft, isLocalOnly, noLocalTimeUpdate,
} : { } : {

View File

@ -969,6 +969,9 @@ export interface ActionPayloads {
focusLastMessage: WithTabId | undefined; focusLastMessage: WithTabId | undefined;
updateDraftReplyInfo: Partial<ApiInputMessageReplyInfo> & WithTabId; updateDraftReplyInfo: Partial<ApiInputMessageReplyInfo> & WithTabId;
resetDraftReplyInfo: WithTabId | undefined; resetDraftReplyInfo: WithTabId | undefined;
updateInsertingPeerIdMention: {
peerId?: string;
} & WithTabId;
// Multitab // Multitab
destroyConnection: undefined; destroyConnection: undefined;

View File

@ -799,4 +799,5 @@ export type TabState = {
isWaitingForStarGiftUpgrade?: true; isWaitingForStarGiftUpgrade?: true;
isWaitingForStarGiftTransfer?: true; isWaitingForStarGiftTransfer?: true;
insertingPeerIdMention?: string;
}; };

View File

@ -1502,6 +1502,7 @@ export interface LangPair {
'ActionPaidMessageGroupPriceFree': undefined; 'ActionPaidMessageGroupPriceFree': undefined;
'NotificationTitleNotSupportedInFrozenAccount': undefined; 'NotificationTitleNotSupportedInFrozenAccount': undefined;
'NotificationMessageNotSupportedInFrozenAccount': undefined; 'NotificationMessageNotSupportedInFrozenAccount': undefined;
'ContextMenuItemMention': undefined;
} }
export interface LangPairWithVariables<V extends unknown = LangVariable> { export interface LangPairWithVariables<V extends unknown = LangVariable> {