From 9c789cdcb84c7478686c9ebccd52a5bd31da7835 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 25 Feb 2022 22:52:48 +0200 Subject: [PATCH] Message List: Support copying multiple messages (#1724) --- src/components/middle/MessageList.tsx | 5 +- .../middle/MessageSelectToolbar.scss | 4 +- .../middle/MessageSelectToolbar.tsx | 9 ++++ .../middle/hooks/useCopySelectedMessages.ts | 24 ++++++++++ .../middle/message/ContextMenuContainer.tsx | 7 +++ .../middle/message/MessageContextMenu.tsx | 4 +- .../middle/message/helpers/copyOptions.ts | 15 ++++-- src/global/types.ts | 2 +- src/hooks/useNativeCopySelectedMessages.ts | 27 +++++++++++ src/modules/actions/ui/messages.ts | 47 +++++++++++++++++++ src/modules/helpers/messageSummary.ts | 17 ++++--- src/util/getMessageIdsForSelectedText.ts | 29 ++++++++++++ 12 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 src/components/middle/hooks/useCopySelectedMessages.ts create mode 100644 src/hooks/useNativeCopySelectedMessages.ts create mode 100644 src/util/getMessageIdsForSelectedText.ts diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index b86c146a6..0c65323e4 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -48,6 +48,7 @@ import renderText from '../common/helpers/renderText'; import useLang from '../../hooks/useLang'; import useWindowSize from '../../hooks/useWindowSize'; import useInterval from '../../hooks/useInterval'; +import useNativeCopySelectedMessages from '../../hooks/useNativeCopySelectedMessages'; import Loading from '../ui/Loading'; import MessageListContent from './MessageListContent'; @@ -138,7 +139,7 @@ const MessageList: FC = ({ withBottomShift, }) => { const { - loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, + loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds, } = getDispatch(); // eslint-disable-next-line no-null/no-null @@ -191,6 +192,8 @@ const MessageList: FC = ({ memoFocusingIdRef.current = focusingId; }, [focusingId]); + useNativeCopySelectedMessages(copyMessagesByIds); + const messageGroups = useMemo(() => { if (!messageIds || !messagesById) { return undefined; diff --git a/src/components/middle/MessageSelectToolbar.scss b/src/components/middle/MessageSelectToolbar.scss index c30f79158..271f04313 100644 --- a/src/components/middle/MessageSelectToolbar.scss +++ b/src/components/middle/MessageSelectToolbar.scss @@ -64,7 +64,7 @@ &::before { z-index: -1; - max-width: 32rem; + max-width: calc(100% * var(--composer-hidden-scale)); width: 100%; left: auto; right: auto; @@ -72,7 +72,7 @@ &-inner { width: 100%; - max-width: 32rem; + max-width: calc(100% * var(--composer-hidden-scale)); display: flex; align-items: center; padding: 0.25rem; diff --git a/src/components/middle/MessageSelectToolbar.tsx b/src/components/middle/MessageSelectToolbar.tsx index df780ce72..1420f4b10 100644 --- a/src/components/middle/MessageSelectToolbar.tsx +++ b/src/components/middle/MessageSelectToolbar.tsx @@ -18,6 +18,7 @@ import captureKeyboardListeners from '../../util/captureKeyboardListeners'; import buildClassName from '../../util/buildClassName'; import usePrevious from '../../hooks/usePrevious'; import useLang from '../../hooks/useLang'; +import useCopySelectedMessages from './hooks/useCopySelectedMessages'; import Button from '../ui/Button'; @@ -58,11 +59,13 @@ const MessageSelectToolbar: FC = ({ exitMessageSelectMode, openForwardMenuForSelectedMessages, downloadSelectedMessages, + copySelectedMessages, } = getDispatch(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); + useCopySelectedMessages(Boolean(isActive), copySelectedMessages); useEffect(() => { return isActive && !isDeleteModalOpen && !isReportModalOpen ? captureKeyboardListeners({ @@ -73,6 +76,11 @@ const MessageSelectToolbar: FC = ({ : undefined; }, [isActive, isDeleteModalOpen, isReportModalOpen, openDeleteModal, exitMessageSelectMode]); + const handleCopy = useCallback(() => { + copySelectedMessages(); + exitMessageSelectMode(); + }, [copySelectedMessages, exitMessageSelectMode]); + const handleDownload = useCallback(() => { downloadSelectedMessages(); exitMessageSelectMode(); @@ -139,6 +147,7 @@ const MessageSelectToolbar: FC = ({ {canDownloadMessages && ( renderButton('download', lang('lng_media_download'), handleDownload, hasProtectedMessage) )} + {renderButton('copy', lang('lng_context_copy_selected_items'), handleCopy, hasProtectedMessage)} {renderButton('delete', lang('EditAdminGroupDeleteMessages'), openDeleteModal, !canDeleteMessages, true)} )} diff --git a/src/components/middle/hooks/useCopySelectedMessages.ts b/src/components/middle/hooks/useCopySelectedMessages.ts new file mode 100644 index 000000000..123fbf96b --- /dev/null +++ b/src/components/middle/hooks/useCopySelectedMessages.ts @@ -0,0 +1,24 @@ +import { useEffect } from '../../../lib/teact/teact'; +import { IS_MAC_OS } from '../../../util/environment'; +import getKeyFromEvent from '../../../util/getKeyFromEvent'; + +const useCopySelectedMessages = (isActive: boolean, copySelectedMessages: NoneToVoidFunction) => { + useEffect(() => { + function handleCopy(e: KeyboardEvent) { + if (((IS_MAC_OS && e.metaKey) || (!IS_MAC_OS && e.ctrlKey)) && getKeyFromEvent(e) === 'c') { + e.preventDefault(); + copySelectedMessages(); + } + } + + if (isActive) { + document.addEventListener('keydown', handleCopy, false); + } + + return () => { + document.removeEventListener('keydown', handleCopy, false); + }; + }, [copySelectedMessages, isActive]); +}; + +export default useCopySelectedMessages; diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 614cff6df..efcc9d8af 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -125,6 +125,7 @@ const ContextMenuContainer: FC = ({ openReactorListModal, loadFullChat, loadReactors, + copyMessagesByIds, } = getDispatch(); const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); @@ -288,6 +289,11 @@ const ContextMenuContainer: FC = ({ }); }, [message.chatId, message.id, rescheduleMessage]); + const handleCopyMessages = useCallback((messageIds: number[]) => { + copyMessagesByIds({ messageIds }); + closeMenu(); + }, [closeMenu, copyMessagesByIds]); + const handleCopyLink = useCallback(() => { copyTextToClipboard(`https://t.me/${chatUsername || `c/${message.chatId.replace('-', '')}`}/${message.id}`); closeMenu(); @@ -366,6 +372,7 @@ const ContextMenuContainer: FC = ({ onReschedule={handleOpenCalendar} onClose={closeMenu} onCopyLink={handleCopyLink} + onCopyMessages={handleCopyMessages} onDownload={handleDownloadClick} onShowSeenBy={handleOpenSeenByModal} onSendReaction={handleSendReaction} diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 458bcdac5..33421f44c 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -63,6 +63,7 @@ type OwnProps = { onClose: () => void; onCloseAnimationEnd?: () => void; onCopyLink?: () => void; + onCopyMessages?: (messageIds: number[]) => void; onDownload?: () => void; onShowSeenBy?: () => void; onShowReactors?: () => void; @@ -120,12 +121,13 @@ const MessageContextMenu: FC = ({ onShowSeenBy, onShowReactors, onSendReaction, + onCopyMessages, }) => { // eslint-disable-next-line no-null/no-null const menuRef = useRef(null); // eslint-disable-next-line no-null/no-null const scrollableRef = useRef(null); - const copyOptions = getMessageCopyOptions(message, onClose, canCopyLink ? onCopyLink : undefined); + const copyOptions = getMessageCopyOptions(message, onClose, canCopyLink ? onCopyLink : undefined, onCopyMessages); const noReactions = !isPrivate && !enabledReactions?.length; const withReactions = canShowReactionList && !noReactions; diff --git a/src/components/middle/message/helpers/copyOptions.ts b/src/components/middle/message/helpers/copyOptions.ts index 633bfc3f2..e6a814bef 100644 --- a/src/components/middle/message/helpers/copyOptions.ts +++ b/src/components/middle/message/helpers/copyOptions.ts @@ -11,6 +11,7 @@ import { hasMessageLocalBlobUrl, } from '../../../../modules/helpers'; import { CLIPBOARD_ITEM_SUPPORTED, copyImageToClipboard, copyTextToClipboard } from '../../../../util/clipboard'; +import getMessageIdsForSelectedText from '../../../../util/getMessageIdsForSelectedText'; type ICopyOptions = { label: string; @@ -18,7 +19,10 @@ type ICopyOptions = { }[]; export function getMessageCopyOptions( - message: ApiMessage, afterEffect?: () => void, onCopyLink?: () => void, + message: ApiMessage, + afterEffect?: () => void, + onCopyLink?: () => void, + onCopyMessages?: (messageIds: number[]) => void, ): ICopyOptions { const options: ICopyOptions = []; const text = getMessageText(message); @@ -53,8 +57,13 @@ export function getMessageCopyOptions( options.push({ label: getCopyLabel(hasSelection), handler: () => { - const clipboardText = hasSelection && selection ? selection.toString() : getMessageTextWithSpoilers(message)!; - copyTextToClipboard(clipboardText); + const messageIds = getMessageIdsForSelectedText(); + if (messageIds?.length && onCopyMessages) { + onCopyMessages(messageIds); + } else { + const clipboardText = hasSelection && selection ? selection.toString() : getMessageTextWithSpoilers(message)!; + copyTextToClipboard(clipboardText); + } if (afterEffect) { afterEffect(); diff --git a/src/global/types.ts b/src/global/types.ts index 160385957..1a6fa88af 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -533,7 +533,7 @@ export type ActionTypes = ( 'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' | 'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' | 'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' | - 'stopActiveReaction' | 'startActiveReaction' | + 'stopActiveReaction' | 'startActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' | // downloads 'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' | // scheduled messages diff --git a/src/hooks/useNativeCopySelectedMessages.ts b/src/hooks/useNativeCopySelectedMessages.ts new file mode 100644 index 000000000..b93c345ce --- /dev/null +++ b/src/hooks/useNativeCopySelectedMessages.ts @@ -0,0 +1,27 @@ +import { useEffect } from '../lib/teact/teact'; +import { IS_MAC_OS } from '../util/environment'; +import getKeyFromEvent from '../util/getKeyFromEvent'; +import getMessageIdsForSelectedText from '../util/getMessageIdsForSelectedText'; + +const useNativeCopySelectedMessages = (copyMessagesByIds: ({ messageIds }: { messageIds?: number[] }) => void) => { + useEffect(() => { + function handleCopy(e: KeyboardEvent) { + if (((IS_MAC_OS && e.metaKey) || (!IS_MAC_OS && e.ctrlKey)) && getKeyFromEvent(e) === 'c') { + const messageIds = getMessageIdsForSelectedText(); + + if (messageIds && messageIds.length > 0) { + e.preventDefault(); + copyMessagesByIds({ messageIds }); + } + } + } + + document.addEventListener('keydown', handleCopy, false); + + return () => { + document.removeEventListener('keydown', handleCopy, false); + }; + }, [copyMessagesByIds]); +}; + +export default useNativeCopySelectedMessages; diff --git a/src/modules/actions/ui/messages.ts b/src/modules/actions/ui/messages.ts index 02e0602bb..4c0db826c 100644 --- a/src/modules/actions/ui/messages.ts +++ b/src/modules/actions/ui/messages.ts @@ -32,12 +32,17 @@ import { selectIsViewportNewest, selectReplyingToId, selectReplyStack, + selectSender, } from '../../selectors'; import { findLast } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; import versionNotification from '../../../versionNotification.txt'; import parseMessageInput from '../../../util/parseMessageInput'; +import { getMessageSummaryText, getSenderTitle } from '../../helpers'; +import * as langProvider from '../../../util/langProvider'; +import { copyTextToClipboard } from '../../../util/clipboard'; +import { GlobalState } from '../../../global/types'; const FOCUS_DURATION = 1500; const FOCUS_NO_HIGHLIGHT_DURATION = FAST_SMOOTH_MAX_DURATION + ANIMATION_END_DELAY; @@ -672,3 +677,45 @@ addReducer('closeSeenByModal', (global) => { seenByModal: undefined, }; }); + +addReducer('copySelectedMessages', (global) => { + if (!global.selectedMessages) { + return; + } + + const { chatId, messageIds } = global.selectedMessages; + copyTextForMessages(global, chatId, messageIds); +}); + +addReducer('copyMessagesByIds', (global, actions, payload: { messageIds?: number[] } ) => { + const { messageIds } = payload; + const chat = selectCurrentChat(global); + if (!messageIds || messageIds.length === 0 || !chat) { + return; + } + + copyTextForMessages(global, chat.id, messageIds); +}); + + +function copyTextForMessages(global: GlobalState, chatId: string, messageIds: number[]) { + const { threadId } = selectCurrentMessageList(global) || {}; + const lang = langProvider.getTranslation; + + const chatMessages = selectChatMessages(global, chatId); + if (!chatMessages || !threadId) return; + const messages = messageIds + .map((id) => chatMessages[id]) + .filter((message) => selectAllowedMessageActions(global, message, threadId).canCopy) + .sort((message1, message2) => message1.id - message2.id); + + const result = messages.reduce((acc, message) => { + const sender = selectSender(global, message); + acc.push(`> ${sender ? getSenderTitle(lang, sender) : ''}:`); + acc.push(getMessageSummaryText(lang, message, false, 0, undefined, true) + '\n'); + + return acc; + }, [] as string[]); + + copyTextToClipboard(result.join('\n')); +} diff --git a/src/modules/helpers/messageSummary.ts b/src/modules/helpers/messageSummary.ts index ca8f22df1..224e320f0 100644 --- a/src/modules/helpers/messageSummary.ts +++ b/src/modules/helpers/messageSummary.ts @@ -16,11 +16,12 @@ export function getMessageSummaryText( noEmoji = false, truncateLength = TRUNCATED_SUMMARY_LENGTH, noReactions = true, + isExtended = false, ) { const emoji = !noEmoji && getMessageSummaryEmoji(message, noReactions); const emojiWithSpace = emoji ? `${emoji} ` : ''; const text = trimText(getMessageTextWithSpoilers(message), truncateLength); - const description = getMessageSummaryDescription(lang, message, text, noReactions); + const description = getMessageSummaryDescription(lang, message, text, noReactions, isExtended); return `${emojiWithSpace}${description}`; } @@ -104,6 +105,7 @@ export function getMessageSummaryDescription( message: ApiMessage, truncatedText?: string | TextPart[], noReactions = true, + isExtended = false, ) { const { text, @@ -133,8 +135,7 @@ export function getMessageSummaryDescription( } if (sticker) { - summary = lang('AttachSticker') - .trim(); + summary = lang('AttachSticker').trim(); } if (audio) { @@ -146,7 +147,7 @@ export function getMessageSummaryDescription( } if (document) { - summary = truncatedText || document.fileName; + summary = isExtended ? document.fileName : (truncatedText || document.fileName); } if (contact) { @@ -158,11 +159,15 @@ export function getMessageSummaryDescription( } if (invoice) { - summary = 'Invoice'; + summary = lang('PaymentInvoice') + ': ' + invoice.text; } if (text) { - summary = truncatedText; + if (isExtended && summary) { + summary += '\n' + truncatedText; + } else { + summary = truncatedText; + } } const reaction = !noReactions && getMessageRecentReaction(message); diff --git a/src/util/getMessageIdsForSelectedText.ts b/src/util/getMessageIdsForSelectedText.ts new file mode 100644 index 000000000..ae716e523 --- /dev/null +++ b/src/util/getMessageIdsForSelectedText.ts @@ -0,0 +1,29 @@ +const ELEMENT_NODE = 1; + +export default function getMessageIdsForSelectedText() { + let selectedFragments = window.getSelection()?.getRangeAt(0).cloneContents(); + if (!selectedFragments || selectedFragments.childElementCount === 0) { + return; + } + + const messageIds = Array.from(selectedFragments.children) + .reduce((result, node) => { + if (node.nodeType === ELEMENT_NODE && node.classList.contains('message-date-group')) { + return Array.from(node.querySelectorAll('.Message')) + .reduce((acc, messageEl) => acc.concat(Number((messageEl as HTMLElement).dataset.messageId)), result); + } else if (node.nodeType === ELEMENT_NODE && node.classList.contains('Message')) { + return result.concat(Number((node as HTMLElement).dataset.messageId)); + } + + return result; + }, [] as number[]); + + + // Cleanup a document fragment because it is playing media content in the background + while (selectedFragments.firstChild) { + selectedFragments.removeChild(selectedFragments.firstChild); + } + selectedFragments = undefined; + + return messageIds; +}