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 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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
memoFocusingIdRef.current = focusingId;
}, [focusingId]);
useNativeCopySelectedMessages(copyMessagesByIds);
const messageGroups = useMemo(() => {
if (!messageIds || !messagesById) {
return undefined;

View File

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

View File

@ -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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
: 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<OwnProps & StateProps> = ({
{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)}
</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,
loadFullChat,
loadReactors,
copyMessagesByIds,
} = getDispatch();
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
@ -288,6 +289,11 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
});
}, [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<OwnProps & StateProps> = ({
onReschedule={handleOpenCalendar}
onClose={closeMenu}
onCopyLink={handleCopyLink}
onCopyMessages={handleCopyMessages}
onDownload={handleDownloadClick}
onShowSeenBy={handleOpenSeenByModal}
onSendReaction={handleSendReaction}

View File

@ -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<OwnProps> = ({
onShowSeenBy,
onShowReactors,
onSendReaction,
onCopyMessages,
}) => {
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-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 withReactions = canShowReactionList && !noReactions;

View File

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

View File

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

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

View File

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

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