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";
"NotificationTitleNotSupportedInFrozenAccount" = "Your account is frozen";
"NotificationMessageNotSupportedInFrozenAccount" = "This action is not available";
"ContextMenuItemMention" = "Mention";

View File

@ -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">

View File

@ -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));

View File

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

View File

@ -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>

View File

@ -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);

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, {
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>
);
};

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>({
global, chatId, threadId, draft, isLocalOnly, noLocalTimeUpdate,
} : {

View File

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

View File

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

View File

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