TelegramPWA/src/components/middle/composer/ComposerEmbeddedMessage.tsx
Alexander Zinchuk 02a5a2a44f Implement Media Editor (#6658)
Co-authored-by: Shahaf Antwarg <santwarg@gmail.com>
2026-03-05 12:43:29 +01:00

577 lines
20 KiB
TypeScript

import {
memo, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import type {
ApiChat, ApiInputMessageReplyInfo, ApiInputSuggestedPostInfo, ApiMessage, ApiPeer,
} from '../../../api/types';
import type { MessageListType, ThemeKey, ThreadId } from '../../../types/index';
import { canEditMediaInEditor, isChatChannel, stripCustomEmoji } from '../../../global/helpers';
import {
selectCanAnimateInterface,
selectChat,
selectChatMessage,
selectEditingMessage,
selectForwardedSender,
selectIsChatWithSelf,
selectIsCurrentUserPremium,
selectSender,
selectTabState,
selectTheme,
} from '../../../global/selectors';
import { selectIsMediaNsfw } from '../../../global/selectors/media';
import { selectDraft, selectEditingId, selectEditingScheduledId } from '../../../global/selectors/threads';
import buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { unique } from '../../../util/iteratees';
import useAppLayout from '../../../hooks/useAppLayout';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useFrozenProps from '../../../hooks/useFrozenProps';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import usePeerColor from '../../../hooks/usePeerColor';
import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated';
import EmbeddedMessage from '../../common/embedded/EmbeddedMessage';
import Icon from '../../common/icons/Icon';
import Button from '../../ui/Button';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import MenuSeparator from '../../ui/MenuSeparator';
import './ComposerEmbeddedMessage.scss';
type StateProps = {
replyInfo?: ApiInputMessageReplyInfo;
suggestedPostInfo?: ApiInputSuggestedPostInfo;
editingId?: number;
message?: ApiMessage;
sender?: ApiPeer;
shouldAnimate?: boolean;
forwardedMessagesCount?: number;
noAuthors?: boolean;
noCaptions?: boolean;
forwardsHaveCaptions?: boolean;
isCurrentUserPremium?: boolean;
isContextMenuDisabled?: boolean;
isReplyToDiscussion?: boolean;
isInChangingRecipientMode?: boolean;
shouldPreventComposerAnimation?: boolean;
senderChat?: ApiChat;
isSenderChannel?: boolean;
currentUserId?: string;
forwardMessageIds?: number[];
fromChatId?: string;
isMediaNsfw?: boolean;
theme: ThemeKey;
};
type OwnProps = {
shouldForceShowEditing?: boolean;
chatId: string;
threadId: ThreadId;
messageListType: MessageListType;
onClear?: NoneToVoidFunction;
};
const CLOSE_DURATION = 350;
const ComposerEmbeddedMessage = (props: OwnProps & StateProps) => {
const {
shouldAnimate,
isReplyToDiscussion,
isInChangingRecipientMode,
forwardMessageIds,
fromChatId,
replyInfo,
editingId,
suggestedPostInfo,
shouldForceShowEditing,
message,
forwardedMessagesCount,
} = props;
const {
resetDraftReplyInfo,
resetDraftSuggestedPostInfo,
updateDraftReplyInfo,
setEditingId,
focusMessage,
changeRecipient,
openChatOrTopicWithReplyInDraft,
setForwardNoAuthors,
setForwardNoCaptions,
exitForwardMode,
setShouldPreventComposerAnimation,
openSuggestMessageModal,
requestMessageMediaEditor,
} = getActions();
const ref = useRef<HTMLDivElement>();
const oldLang = useOldLang();
const lang = useLang();
const { isMobile } = useAppLayout();
const isReplyToTopicStart = message?.content.action?.type === 'topicCreate';
const isShowingSuggestedPost = Boolean(suggestedPostInfo) && !shouldForceShowEditing;
const isForwarding = Boolean(forwardedMessagesCount);
const selectSenderFromForwardedMessage = useLastCallback((forwardedMessage: ApiMessage) => {
const global = getGlobal();
let localSender = selectForwardedSender(global, forwardedMessage);
if (!localSender) {
localSender = selectSender(global, forwardedMessage);
}
return localSender;
});
const forwardSenders = useMemo(() => {
if (!isForwarding) return undefined;
const forwardedMessages = forwardMessageIds?.map((id) => selectChatMessage(getGlobal(), fromChatId!, id))
.filter(Boolean);
const senders = forwardedMessages?.map((m) => selectSenderFromForwardedMessage(m)).filter(Boolean);
return senders ? unique(senders) : undefined;
}, [isForwarding, forwardMessageIds, fromChatId]);
const isShown = (() => {
if (isInChangingRecipientMode) return false;
if (message && (replyInfo || editingId)) return true;
if (forwardSenders && isForwarding) return true;
if (isShowingSuggestedPost) return true;
return false;
})();
const {
shouldRender, transitionClassNames, isClosing,
} = useShowTransitionDeprecated(
isShown && !isReplyToTopicStart && !isReplyToDiscussion,
undefined,
!shouldAnimate,
undefined,
!shouldAnimate,
CLOSE_DURATION,
!shouldAnimate,
);
const {
chatId,
currentUserId,
theme,
onClear,
isCurrentUserPremium,
isContextMenuDisabled,
shouldPreventComposerAnimation,
sender,
senderChat,
isMediaNsfw,
noAuthors,
noCaptions,
forwardsHaveCaptions,
forwardedMessagesCount: frozenForwardedMessagesCount,
message: frozenMessage,
shouldForceShowEditing: frozenShouldForceShowEditing,
suggestedPostInfo: frozenSuggestedPostInfo,
replyInfo: frozenReplyInfo,
editingId: frozenEditingId,
isSenderChannel,
} = useFrozenProps(props, isClosing);
const isForwardingRendering = Boolean(frozenForwardedMessagesCount);
const isShowingReplyRendering = Boolean(frozenReplyInfo) && !frozenShouldForceShowEditing;
const isReplyWithQuoteRendering = Boolean(frozenReplyInfo?.quoteText);
const isShowingSuggestedPostRendering = Boolean(frozenSuggestedPostInfo) && !frozenShouldForceShowEditing;
const canMediaBeEdited = frozenMessage && canEditMediaInEditor(frozenMessage) && !isMobile;
useEffect(() => {
if (shouldPreventComposerAnimation) {
setShouldPreventComposerAnimation({ shouldPreventComposerAnimation: false });
}
});
const clearEmbedded = useLastCallback(() => {
if (frozenEditingId) {
setEditingId({ messageId: undefined });
} else if (frozenForwardedMessagesCount) {
exitForwardMode();
} else if (isShowingSuggestedPostRendering) {
resetDraftSuggestedPostInfo();
resetDraftReplyInfo();
} else if (frozenReplyInfo && !frozenShouldForceShowEditing) {
resetDraftReplyInfo();
}
onClear?.();
});
useEffect(() => (isShown ? captureEscKeyListener(clearEmbedded) : undefined), [isShown, clearEmbedded]);
const {
isContextMenuOpen, contextMenuAnchor, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref);
const focusMessageFromDraft = () => {
focusMessage({ chatId: frozenMessage!.chatId, messageId: frozenMessage!.id, noForumTopicPanel: true });
};
const handleMessageClick = useLastCallback((e: React.MouseEvent): void => {
if (frozenSuggestedPostInfo) {
openSuggestMessageModal({ chatId });
return;
}
handleContextMenu(e);
});
const handlePictogramClick = useLastCallback((e: React.MouseEvent): void => {
e.stopPropagation();
if ((frozenEditingId || frozenReplyInfo?.type === 'message') && canMediaBeEdited) {
requestMessageMediaEditor();
return;
}
});
const handleClearClick = useLastCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
e.stopPropagation();
clearEmbedded();
handleContextMenuHide();
});
const buildAutoCloseMenuItemHandler = (action: NoneToVoidFunction) => {
return () => {
handleContextMenuClose();
action();
};
};
const handleForwardToAnotherChatClick = useLastCallback(buildAutoCloseMenuItemHandler(changeRecipient));
const handleShowMessageClick = useLastCallback(buildAutoCloseMenuItemHandler(focusMessageFromDraft));
const handleRemoveQuoteClick = useLastCallback(buildAutoCloseMenuItemHandler(
() => updateDraftReplyInfo({ quoteText: undefined }),
));
const handleChangeReplyRecipientClick = useLastCallback(buildAutoCloseMenuItemHandler(changeRecipient));
const handleReplyInSenderChat = useLastCallback(() => {
handleContextMenuClose();
if (!sender) return;
openChatOrTopicWithReplyInDraft({ chatId: sender.id });
});
const handleDoNotReplyClick = useLastCallback(buildAutoCloseMenuItemHandler(clearEmbedded));
const handleIconKeyDown = useLastCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleContextMenu(e as unknown as React.MouseEvent);
}
});
const getTriggerElement = useLastCallback(() => ref.current);
const getRootElement = useLastCallback(() => ref.current!);
const getMenuElement = useLastCallback(() => ref.current!.querySelector('.forward-context-menu .bubble'));
useEffect(() => {
if (!shouldRender) {
handleContextMenuClose();
handleContextMenuHide();
}
}, [handleContextMenuClose, handleContextMenuHide, shouldRender]);
const className = buildClassName('ComposerEmbeddedMessage', transitionClassNames);
const { className: peerColorClass, style: peerColorStyle } = usePeerColor({
peer: sender,
theme,
});
const innerClassName = buildClassName('ComposerEmbeddedMessage_inner', peerColorClass);
const leftIcon = useMemo(() => {
if (frozenEditingId) {
return 'edit';
}
if (isShowingSuggestedPostRendering) {
return 'cash-circle';
}
if (isForwardingRendering) {
return 'forward';
}
if (isShowingReplyRendering) {
return 'reply';
}
return undefined;
}, [frozenEditingId, isForwardingRendering, isShowingReplyRendering, isShowingSuggestedPostRendering]);
const customText = frozenForwardedMessagesCount && frozenForwardedMessagesCount > 1
? oldLang('ForwardedMessageCount', frozenForwardedMessagesCount)
: undefined;
const strippedMessage = useMemo(() => {
if (!frozenMessage || !isForwardingRendering || !frozenMessage.content.text
|| !noAuthors || isCurrentUserPremium) return frozenMessage;
const strippedText = stripCustomEmoji(frozenMessage.content.text);
return {
...frozenMessage,
content: {
...frozenMessage.content,
text: strippedText,
},
};
}, [isCurrentUserPremium, isForwardingRendering, frozenMessage, noAuthors]);
const renderingLeftIcon = useCurrentOrPrev(leftIcon, true);
if (!shouldRender) {
return undefined;
}
const canReplyInSenderChat = sender && !isSenderChannel
&& chatId !== sender.id && sender.id !== currentUserId;
return (
<div className={className} ref={ref} onContextMenu={handleContextMenu}>
<div className={innerClassName} style={peerColorStyle}>
<div
className="embedded-left-icon"
role="button"
tabIndex={0}
onClick={handleContextMenu}
onKeyDown={handleIconKeyDown}
>
{renderingLeftIcon && <Icon name={renderingLeftIcon} />}
{Boolean(frozenReplyInfo?.quoteText) && (
<Icon name="quote" className="quote-reply" />
)}
</div>
<EmbeddedMessage
isOpen={isShown}
className="inside-input"
replyInfo={frozenReplyInfo}
suggestedPostInfo={frozenSuggestedPostInfo}
isMediaNsfw={isMediaNsfw}
isInComposer
message={strippedMessage}
sender={!noAuthors ? sender : undefined}
composerForwardSenders={forwardSenders}
customText={customText}
noCaptions={noCaptions}
title={(frozenEditingId && !isShowingReplyRendering) ? oldLang('EditMessage')
: noAuthors ? oldLang('HiddenSendersNameDescription') : undefined}
onClick={handleMessageClick}
onPictogramClick={canMediaBeEdited ? handlePictogramClick : undefined}
pictogramActionIcon={canMediaBeEdited ? 'edit' : undefined}
senderChat={senderChat}
/>
<Button
className="embedded-cancel"
round
faded
color="translucent"
ariaLabel={oldLang('Cancel')}
onClick={handleClearClick}
iconName="close"
/>
{(isShowingReplyRendering || isForwardingRendering) && !isContextMenuDisabled && (
<Menu
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
className="forward-context-menu"
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
>
{isForwardingRendering && (
<>
<MenuItem
icon={!noAuthors ? 'message-succeeded' : undefined}
customIcon={noAuthors ? <Icon name="placeholder" /> : undefined}
onClick={() => setForwardNoAuthors({
noAuthors: false,
})}
>
{oldLang(frozenForwardedMessagesCount > 1 ? 'ShowSenderNames' : 'ShowSendersName')}
</MenuItem>
<MenuItem
icon={noAuthors ? 'message-succeeded' : undefined}
customIcon={!noAuthors ? <Icon name="placeholder" /> : undefined}
onClick={() => setForwardNoAuthors({
noAuthors: true,
})}
>
{oldLang(frozenForwardedMessagesCount > 1 ? 'HideSenderNames' : 'HideSendersName')}
</MenuItem>
{forwardsHaveCaptions && (
<>
<MenuSeparator />
<MenuItem
icon={!noCaptions ? 'message-succeeded' : undefined}
customIcon={noCaptions ? <Icon name="placeholder" /> : undefined}
onClick={() => setForwardNoCaptions({
noCaptions: false,
})}
>
{oldLang(frozenForwardedMessagesCount > 1
? 'Conversation.ForwardOptions.ShowCaption' : 'ShowCaption')}
</MenuItem>
<MenuItem
icon={noCaptions ? 'message-succeeded' : undefined}
customIcon={!noCaptions ? <Icon name="placeholder" /> : undefined}
onClick={() => setForwardNoCaptions({
noCaptions: true,
})}
>
{oldLang(frozenForwardedMessagesCount > 1
? 'Conversation.ForwardOptions.HideCaption' : 'HideCaption')}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem icon="replace" onClick={handleForwardToAnotherChatClick}>
{oldLang('ForwardAnotherChat')}
</MenuItem>
</>
)}
{isShowingReplyRendering && (
<>
<MenuItem
icon="show-message"
onClick={handleShowMessageClick}
>
{oldLang('Message.Context.Goto')}
</MenuItem>
{isReplyWithQuoteRendering && (
<MenuItem
icon="remove-quote"
onClick={handleRemoveQuoteClick}
>
{oldLang('RemoveQuote')}
</MenuItem>
)}
{canReplyInSenderChat && (
<MenuItem icon="user" onClick={handleReplyInSenderChat}>
{lang('ReplyInPrivateMessage')}
</MenuItem>
)}
<MenuItem icon="replace" onClick={handleChangeReplyRecipientClick}>
{oldLang('ReplyToAnotherChat')}
</MenuItem>
<MenuItem icon="delete" onClick={handleDoNotReplyClick}>
{oldLang('DoNotReply')}
</MenuItem>
</>
)}
</Menu>
)}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, {
shouldForceShowEditing, chatId, threadId, messageListType,
}): Complete<StateProps> => {
const {
forwardMessages: {
fromChatId, toChatId, messageIds: forwardMessageIds, noAuthors, noCaptions,
},
isShareMessageModalShown: isModalShown,
shouldPreventComposerAnimation,
} = selectTabState(global);
const editingId = messageListType === 'scheduled'
? selectEditingScheduledId(global, chatId)
: selectEditingId(global, chatId, threadId);
const shouldAnimate = selectCanAnimateInterface(global) && !shouldPreventComposerAnimation;
const isForwarding = toChatId === chatId;
const forwardedMessages = forwardMessageIds?.map((id) => selectChatMessage(global, fromChatId!, id)!);
const draft = selectDraft(global, chatId, threadId);
const replyInfo = draft?.replyInfo;
const suggestedPostInfo = draft?.suggestedPostInfo;
const replyToPeerId = replyInfo?.replyToPeerId;
const senderChat = replyToPeerId ? selectChat(global, replyToPeerId) : undefined;
let message: ApiMessage | undefined;
if (editingId) {
message = selectEditingMessage(global, chatId, threadId, messageListType);
} else if (isForwarding && forwardMessageIds!.length === 1) {
message = forwardedMessages?.[0];
} else if (replyInfo && !shouldForceShowEditing) {
message = selectChatMessage(global, replyInfo.replyToPeerId || chatId, replyInfo.replyToMsgId);
}
let sender: ApiPeer | undefined;
const selectSenderFromForwardedMessage = (forwardedMessage: ApiMessage) => {
sender = selectForwardedSender(global, forwardedMessage);
if (!sender) {
sender = selectSender(global, forwardedMessage);
}
return sender;
};
if (editingId && message) {
sender = selectSender(global, message);
} else if (isForwarding) {
let forwardSenders = forwardedMessages?.map((m) => selectSenderFromForwardedMessage(m)).filter(Boolean);
forwardSenders = forwardSenders ? unique(forwardSenders) : undefined;
sender = forwardSenders?.length === 1 ? forwardSenders?.[0] : undefined;
} else if (replyInfo && message && !shouldForceShowEditing) {
const { forwardInfo } = message;
const isChatWithSelf = selectIsChatWithSelf(global, chatId);
if (forwardInfo && (forwardInfo.isChannelPost || isChatWithSelf)) {
sender = selectForwardedSender(global, message);
}
if (!sender && (!forwardInfo?.hiddenUserName || Boolean(replyInfo.quoteText))) {
sender = selectSender(global, message);
}
}
const chat = sender && selectChat(global, sender.id);
const isSenderChannel = chat && isChatChannel(chat);
const forwardsHaveCaptions = forwardedMessages?.some((forward) => (
forward?.content.text && Object.keys(forward.content).length > 1
));
const isContextMenuDisabled = isForwarding && forwardMessageIds!.length === 1
&& Boolean(message?.content.storyData);
const isReplyToDiscussion = replyInfo?.replyToMsgId === threadId && !replyInfo.replyToPeerId;
const isMediaNsfw = message && selectIsMediaNsfw(global, message);
return {
replyInfo,
suggestedPostInfo,
editingId,
message,
sender,
shouldAnimate,
forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined,
noAuthors,
noCaptions,
forwardsHaveCaptions,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
isContextMenuDisabled,
isReplyToDiscussion,
isInChangingRecipientMode: isModalShown,
shouldPreventComposerAnimation,
senderChat,
currentUserId: global.currentUserId,
isSenderChannel,
forwardMessageIds,
fromChatId,
isMediaNsfw,
theme: selectTheme(global),
};
},
)(ComposerEmbeddedMessage));