Message List: Support copying multiple messages (#1724)
This commit is contained in:
parent
abd10ba9b8
commit
9c789cdcb8
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
24
src/components/middle/hooks/useCopySelectedMessages.ts
Normal file
24
src/components/middle/hooks/useCopySelectedMessages.ts
Normal 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;
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
27
src/hooks/useNativeCopySelectedMessages.ts
Normal file
27
src/hooks/useNativeCopySelectedMessages.ts
Normal 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;
|
||||
@ -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'));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
29
src/util/getMessageIdsForSelectedText.ts
Normal file
29
src/util/getMessageIdsForSelectedText.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user