Message List: Support copying multiple messages (#1724)

This commit is contained in:
Alexander Zinchuk 2022-02-25 22:52:48 +02:00
parent abd10ba9b8
commit 9c789cdcb8
12 changed files with 176 additions and 14 deletions

View File

@ -48,6 +48,7 @@ import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useWindowSize from '../../hooks/useWindowSize'; import useWindowSize from '../../hooks/useWindowSize';
import useInterval from '../../hooks/useInterval'; import useInterval from '../../hooks/useInterval';
import useNativeCopySelectedMessages from '../../hooks/useNativeCopySelectedMessages';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
import MessageListContent from './MessageListContent'; import MessageListContent from './MessageListContent';
@ -138,7 +139,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
withBottomShift, withBottomShift,
}) => { }) => {
const { const {
loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, loadViewportMessages, setScrollOffset, loadSponsoredMessages, loadMessageReactions, copyMessagesByIds,
} = getDispatch(); } = getDispatch();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -191,6 +192,8 @@ const MessageList: FC<OwnProps & StateProps> = ({
memoFocusingIdRef.current = focusingId; memoFocusingIdRef.current = focusingId;
}, [focusingId]); }, [focusingId]);
useNativeCopySelectedMessages(copyMessagesByIds);
const messageGroups = useMemo(() => { const messageGroups = useMemo(() => {
if (!messageIds || !messagesById) { if (!messageIds || !messagesById) {
return undefined; return undefined;

View File

@ -64,7 +64,7 @@
&::before { &::before {
z-index: -1; z-index: -1;
max-width: 32rem; max-width: calc(100% * var(--composer-hidden-scale));
width: 100%; width: 100%;
left: auto; left: auto;
right: auto; right: auto;
@ -72,7 +72,7 @@
&-inner { &-inner {
width: 100%; width: 100%;
max-width: 32rem; max-width: calc(100% * var(--composer-hidden-scale));
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.25rem; padding: 0.25rem;

View File

@ -18,6 +18,7 @@ import captureKeyboardListeners from '../../util/captureKeyboardListeners';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import usePrevious from '../../hooks/usePrevious'; import usePrevious from '../../hooks/usePrevious';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useCopySelectedMessages from './hooks/useCopySelectedMessages';
import Button from '../ui/Button'; import Button from '../ui/Button';
@ -58,11 +59,13 @@ const MessageSelectToolbar: FC<OwnProps & StateProps> = ({
exitMessageSelectMode, exitMessageSelectMode,
openForwardMenuForSelectedMessages, openForwardMenuForSelectedMessages,
downloadSelectedMessages, downloadSelectedMessages,
copySelectedMessages,
} = getDispatch(); } = getDispatch();
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag();
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
useCopySelectedMessages(Boolean(isActive), copySelectedMessages);
useEffect(() => { useEffect(() => {
return isActive && !isDeleteModalOpen && !isReportModalOpen return isActive && !isDeleteModalOpen && !isReportModalOpen
? captureKeyboardListeners({ ? captureKeyboardListeners({
@ -73,6 +76,11 @@ const MessageSelectToolbar: FC<OwnProps & StateProps> = ({
: undefined; : undefined;
}, [isActive, isDeleteModalOpen, isReportModalOpen, openDeleteModal, exitMessageSelectMode]); }, [isActive, isDeleteModalOpen, isReportModalOpen, openDeleteModal, exitMessageSelectMode]);
const handleCopy = useCallback(() => {
copySelectedMessages();
exitMessageSelectMode();
}, [copySelectedMessages, exitMessageSelectMode]);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
downloadSelectedMessages(); downloadSelectedMessages();
exitMessageSelectMode(); exitMessageSelectMode();
@ -139,6 +147,7 @@ const MessageSelectToolbar: FC<OwnProps & StateProps> = ({
{canDownloadMessages && ( {canDownloadMessages && (
renderButton('download', lang('lng_media_download'), handleDownload, hasProtectedMessage) 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)} {renderButton('delete', lang('EditAdminGroupDeleteMessages'), openDeleteModal, !canDeleteMessages, true)}
</div> </div>
)} )}

View File

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

View File

@ -125,6 +125,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
openReactorListModal, openReactorListModal,
loadFullChat, loadFullChat,
loadReactors, loadReactors,
copyMessagesByIds,
} = getDispatch(); } = getDispatch();
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
@ -288,6 +289,11 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
}); });
}, [message.chatId, message.id, rescheduleMessage]); }, [message.chatId, message.id, rescheduleMessage]);
const handleCopyMessages = useCallback((messageIds: number[]) => {
copyMessagesByIds({ messageIds });
closeMenu();
}, [closeMenu, copyMessagesByIds]);
const handleCopyLink = useCallback(() => { const handleCopyLink = useCallback(() => {
copyTextToClipboard(`https://t.me/${chatUsername || `c/${message.chatId.replace('-', '')}`}/${message.id}`); copyTextToClipboard(`https://t.me/${chatUsername || `c/${message.chatId.replace('-', '')}`}/${message.id}`);
closeMenu(); closeMenu();
@ -366,6 +372,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onReschedule={handleOpenCalendar} onReschedule={handleOpenCalendar}
onClose={closeMenu} onClose={closeMenu}
onCopyLink={handleCopyLink} onCopyLink={handleCopyLink}
onCopyMessages={handleCopyMessages}
onDownload={handleDownloadClick} onDownload={handleDownloadClick}
onShowSeenBy={handleOpenSeenByModal} onShowSeenBy={handleOpenSeenByModal}
onSendReaction={handleSendReaction} onSendReaction={handleSendReaction}

View File

@ -63,6 +63,7 @@ type OwnProps = {
onClose: () => void; onClose: () => void;
onCloseAnimationEnd?: () => void; onCloseAnimationEnd?: () => void;
onCopyLink?: () => void; onCopyLink?: () => void;
onCopyMessages?: (messageIds: number[]) => void;
onDownload?: () => void; onDownload?: () => void;
onShowSeenBy?: () => void; onShowSeenBy?: () => void;
onShowReactors?: () => void; onShowReactors?: () => void;
@ -120,12 +121,13 @@ const MessageContextMenu: FC<OwnProps> = ({
onShowSeenBy, onShowSeenBy,
onShowReactors, onShowReactors,
onSendReaction, onSendReaction,
onCopyMessages,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const scrollableRef = useRef<HTMLDivElement>(null); const scrollableRef = useRef<HTMLDivElement>(null);
const copyOptions = getMessageCopyOptions(message, onClose, canCopyLink ? onCopyLink : undefined); const copyOptions = getMessageCopyOptions(message, onClose, canCopyLink ? onCopyLink : undefined, onCopyMessages);
const noReactions = !isPrivate && !enabledReactions?.length; const noReactions = !isPrivate && !enabledReactions?.length;
const withReactions = canShowReactionList && !noReactions; const withReactions = canShowReactionList && !noReactions;

View File

@ -11,6 +11,7 @@ import {
hasMessageLocalBlobUrl, hasMessageLocalBlobUrl,
} from '../../../../modules/helpers'; } from '../../../../modules/helpers';
import { CLIPBOARD_ITEM_SUPPORTED, copyImageToClipboard, copyTextToClipboard } from '../../../../util/clipboard'; import { CLIPBOARD_ITEM_SUPPORTED, copyImageToClipboard, copyTextToClipboard } from '../../../../util/clipboard';
import getMessageIdsForSelectedText from '../../../../util/getMessageIdsForSelectedText';
type ICopyOptions = { type ICopyOptions = {
label: string; label: string;
@ -18,7 +19,10 @@ type ICopyOptions = {
}[]; }[];
export function getMessageCopyOptions( export function getMessageCopyOptions(
message: ApiMessage, afterEffect?: () => void, onCopyLink?: () => void, message: ApiMessage,
afterEffect?: () => void,
onCopyLink?: () => void,
onCopyMessages?: (messageIds: number[]) => void,
): ICopyOptions { ): ICopyOptions {
const options: ICopyOptions = []; const options: ICopyOptions = [];
const text = getMessageText(message); const text = getMessageText(message);
@ -53,8 +57,13 @@ export function getMessageCopyOptions(
options.push({ options.push({
label: getCopyLabel(hasSelection), label: getCopyLabel(hasSelection),
handler: () => { handler: () => {
const clipboardText = hasSelection && selection ? selection.toString() : getMessageTextWithSpoilers(message)!; const messageIds = getMessageIdsForSelectedText();
copyTextToClipboard(clipboardText); if (messageIds?.length && onCopyMessages) {
onCopyMessages(messageIds);
} else {
const clipboardText = hasSelection && selection ? selection.toString() : getMessageTextWithSpoilers(message)!;
copyTextToClipboard(clipboardText);
}
if (afterEffect) { if (afterEffect) {
afterEffect(); afterEffect();

View File

@ -533,7 +533,7 @@ export type ActionTypes = (
'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' | 'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' |
'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' | 'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' |
'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' | 'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' |
'stopActiveReaction' | 'startActiveReaction' | 'stopActiveReaction' | 'startActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' |
// downloads // downloads
'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' | 'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' |
// scheduled messages // scheduled messages

View File

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

View File

@ -32,12 +32,17 @@ import {
selectIsViewportNewest, selectIsViewportNewest,
selectReplyingToId, selectReplyingToId,
selectReplyStack, selectReplyStack,
selectSender,
} from '../../selectors'; } from '../../selectors';
import { findLast } from '../../../util/iteratees'; import { findLast } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import versionNotification from '../../../versionNotification.txt'; import versionNotification from '../../../versionNotification.txt';
import parseMessageInput from '../../../util/parseMessageInput'; 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_DURATION = 1500;
const FOCUS_NO_HIGHLIGHT_DURATION = FAST_SMOOTH_MAX_DURATION + ANIMATION_END_DELAY; const FOCUS_NO_HIGHLIGHT_DURATION = FAST_SMOOTH_MAX_DURATION + ANIMATION_END_DELAY;
@ -672,3 +677,45 @@ addReducer('closeSeenByModal', (global) => {
seenByModal: undefined, 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'));
}

View File

@ -16,11 +16,12 @@ export function getMessageSummaryText(
noEmoji = false, noEmoji = false,
truncateLength = TRUNCATED_SUMMARY_LENGTH, truncateLength = TRUNCATED_SUMMARY_LENGTH,
noReactions = true, noReactions = true,
isExtended = false,
) { ) {
const emoji = !noEmoji && getMessageSummaryEmoji(message, noReactions); const emoji = !noEmoji && getMessageSummaryEmoji(message, noReactions);
const emojiWithSpace = emoji ? `${emoji} ` : ''; const emojiWithSpace = emoji ? `${emoji} ` : '';
const text = trimText(getMessageTextWithSpoilers(message), truncateLength); const text = trimText(getMessageTextWithSpoilers(message), truncateLength);
const description = getMessageSummaryDescription(lang, message, text, noReactions); const description = getMessageSummaryDescription(lang, message, text, noReactions, isExtended);
return `${emojiWithSpace}${description}`; return `${emojiWithSpace}${description}`;
} }
@ -104,6 +105,7 @@ export function getMessageSummaryDescription(
message: ApiMessage, message: ApiMessage,
truncatedText?: string | TextPart[], truncatedText?: string | TextPart[],
noReactions = true, noReactions = true,
isExtended = false,
) { ) {
const { const {
text, text,
@ -133,8 +135,7 @@ export function getMessageSummaryDescription(
} }
if (sticker) { if (sticker) {
summary = lang('AttachSticker') summary = lang('AttachSticker').trim();
.trim();
} }
if (audio) { if (audio) {
@ -146,7 +147,7 @@ export function getMessageSummaryDescription(
} }
if (document) { if (document) {
summary = truncatedText || document.fileName; summary = isExtended ? document.fileName : (truncatedText || document.fileName);
} }
if (contact) { if (contact) {
@ -158,11 +159,15 @@ export function getMessageSummaryDescription(
} }
if (invoice) { if (invoice) {
summary = 'Invoice'; summary = lang('PaymentInvoice') + ': ' + invoice.text;
} }
if (text) { if (text) {
summary = truncatedText; if (isExtended && summary) {
summary += '\n' + truncatedText;
} else {
summary = truncatedText;
}
} }
const reaction = !noReactions && getMessageRecentReaction(message); const reaction = !noReactions && getMessageRecentReaction(message);

View File

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