From 7874fbbb0377628c22a0837d25ed16b5c91b1822 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Wed, 14 May 2025 19:02:19 +0300 Subject: [PATCH] Sender Group Container: Support context menu for avatars (#5900) --- src/assets/localization/fallback.strings | 1 + src/components/common/Avatar.tsx | 3 + src/components/common/Composer.tsx | 15 +++ src/components/middle/MessageList.tsx | 1 + src/components/middle/MessageListContent.tsx | 3 + .../composer/hooks/useMentionTooltip.ts | 25 +++-- .../message/SenderGroupContainer.module.scss | 8 ++ .../middle/message/SenderGroupContainer.tsx | 94 ++++++++++++++++++- src/global/actions/api/messages.ts | 7 ++ src/global/types/actions.ts | 3 + src/global/types/tabState.ts | 1 + src/types/language.d.ts | 1 + 12 files changed, 149 insertions(+), 13 deletions(-) diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 7fe60b864..a647fe85a 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1953,3 +1953,4 @@ "ApiMessageActionPaidMessagesRefundedIncoming" = "{user} refunded **{stars}** to you"; "NotificationTitleNotSupportedInFrozenAccount" = "Your account is frozen"; "NotificationMessageNotSupportedInFrozenAccount" = "This action is not available"; +"ContextMenuItemMention" = "Mention"; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 42b4ab6df..4f4b1bc81 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -86,6 +86,7 @@ type OwnProps = { noPersonalPhoto?: boolean; observeIntersection?: ObserveFn; onClick?: (e: ReactMouseEvent, hasMedia: boolean) => void; + onContextMenu?: (e: React.MouseEvent) => void; }; const Avatar: FC = ({ @@ -110,6 +111,7 @@ const Avatar: FC = ({ loopIndefinitely, noPersonalPhoto, onClick, + onContextMenu, }) => { const { openStoryViewer } = getActions(); @@ -297,6 +299,7 @@ const Avatar: FC = ({ aria-label={typeof content === 'string' ? author : undefined} style={buildStyle(`--_size: ${pxSize}px;`, customColor && `--color-user: ${customColor}`)} onClick={handleClick} + onContextMenu={onContextMenu} onMouseDown={handleMouseDown} >
diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 6a9153ae1..6088e64e2 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -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 = ({ disallowedGifts, isAccountFrozen, isAppConfigLoaded, + insertingPeerIdMention, }) => { const { sendMessage, @@ -448,6 +451,7 @@ const Composer: FC = ({ setReactionEffect, hideEffectInComposer, updateChatSilentPosting, + updateInsertingPeerIdMention, } = getActions(); const oldLang = useOldLang(); @@ -751,6 +755,15 @@ const Composer: FC = ({ 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( 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( disallowedGifts: userFullInfo?.disallowedGifts, isAccountFrozen, isAppConfigLoaded, + insertingPeerIdMention, }; }, )(Composer)); diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index f95ba94d8..5bc14b2d6 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -750,6 +750,7 @@ const MessageList: FC = ({ onScrollDownToggle={onScrollDownToggle} onNotchToggle={onNotchToggle} onIntersectPinnedMessage={onIntersectPinnedMessage} + canPost={canPost} /> ) : ( diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 91021b064..e33eca212 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -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 = ({ onScrollDownToggle, onNotchToggle, onIntersectPinnedMessage, + canPost, }) => { const { openHistoryCalendar } = getActions(); @@ -325,6 +327,7 @@ const MessageListContent: FC = ({ message={lastMessage} withAvatar={withAvatar} appearanceOrder={lastAppearanceOrder} + canPost={canPost} > {senderGroupElements} diff --git a/src/components/middle/composer/hooks/useMentionTooltip.ts b/src/components/middle/composer/hooks/useMentionTooltip.ts index dcf16fe59..066653f9a 100644 --- a/src/components/middle/composer/hooks/useMentionTooltip.ts +++ b/src/components/middle/composer/hooks/useMentionTooltip.ts @@ -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(); 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}` : `${userFirstOrLastName}`; @@ -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); diff --git a/src/components/middle/message/SenderGroupContainer.module.scss b/src/components/middle/message/SenderGroupContainer.module.scss index af665afdf..c07dcc2fc 100644 --- a/src/components/middle/message/SenderGroupContainer.module.scss +++ b/src/components/middle/message/SenderGroupContainer.module.scss @@ -32,3 +32,11 @@ } } } + +.contextMenu { + position: absolute; + + :global(.bubble) { + width: auto; + } +} diff --git a/src/components/middle/message/SenderGroupContainer.tsx b/src/components/middle/message/SenderGroupContainer.tsx index ba96c92df..cde423b91 100644 --- a/src/components/middle/message/SenderGroupContainer.tsx +++ b/src/components/middle/message/SenderGroupContainer.tsx @@ -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 = ({ isChatWithSelf, isRepliesChat, isAnonymousForwards, + canPost, }) => { - const { openChat } = getActions(); + const { openChat, updateInsertingPeerIdMention } = getActions(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(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 = ({ 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 = ({ openChat({ id: avatarPeer.id }); }); + const handleMention = useLastCallback(() => { + if (!avatarPeer) { + return; + } + + const messageInput = document.querySelector(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 = ({ 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 ( + + <> + {isAvatarPeerUser && ( + + {lang('SendMessage')} + + )} + {canMention && ( + + {lang('ContextMenuItemMention')} + + )} + + + ); + } + function renderAvatar() { const hiddenName = (!avatarPeer && forwardInfo) ? forwardInfo.hiddenUserName : undefined; @@ -108,6 +192,7 @@ const SenderGroupContainer: FC = ({ peer={avatarPeer} text={hiddenName} onClick={avatarPeer ? handleAvatarClick : undefined} + onContextMenu={handleContextMenu} /> ); } @@ -118,13 +203,14 @@ const SenderGroupContainer: FC = ({ ); return ( -
+
{shouldRender && (
{renderAvatar()}
)} {children} + {shouldRenderContextMenu && renderContextMenu()}
); }; diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 994b367e9..5f69f2efc 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -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({ global, chatId, threadId, draft, isLocalOnly, noLocalTimeUpdate, } : { diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 4f3668cbc..2c02196dd 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -969,6 +969,9 @@ export interface ActionPayloads { focusLastMessage: WithTabId | undefined; updateDraftReplyInfo: Partial & WithTabId; resetDraftReplyInfo: WithTabId | undefined; + updateInsertingPeerIdMention: { + peerId?: string; + } & WithTabId; // Multitab destroyConnection: undefined; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 49bbea0fe..72f43b7ec 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -799,4 +799,5 @@ export type TabState = { isWaitingForStarGiftUpgrade?: true; isWaitingForStarGiftTransfer?: true; + insertingPeerIdMention?: string; }; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 21af3fcd0..aaac897ea 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1502,6 +1502,7 @@ export interface LangPair { 'ActionPaidMessageGroupPriceFree': undefined; 'NotificationTitleNotSupportedInFrozenAccount': undefined; 'NotificationMessageNotSupportedInFrozenAccount': undefined; + 'ContextMenuItemMention': undefined; } export interface LangPairWithVariables {