Adds keyboard shortcut for editing last message sent

This commit is contained in:
Josh Perez 2023-05-11 20:27:19 -04:00 committed by GitHub
parent a1fd4e55ee
commit 216ee67c50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 675 additions and 550 deletions

View file

@ -5454,6 +5454,10 @@
"messageformat": "Jump to chat",
"description": "A shortcut allowing direct navigation to conversations 1 to 9 in list"
},
"icu:Keyboard--edit-last-message": {
"messageformat": "Edit the previous message",
"description": "Shown in the shortcuts guide"
},
"Keyboard--Key--ctrl": {
"message": "Ctrl",
"description": "(deleted 03/29/2023) Key shown in shortcut combination in shortcuts guide"

View file

@ -171,10 +171,6 @@ import type AccountManager from './textsecure/AccountManager';
import { onStoryRecipientUpdate } from './util/onStoryRecipientUpdate';
import { StoryViewModeType, StoryViewTargetType } from './types/Stories';
import { downloadOnboardingStory } from './util/downloadOnboardingStory';
import { clearConversationDraftAttachments } from './util/clearConversationDraftAttachments';
import { removeLinkPreview } from './services/LinkPreview';
import { PanelType } from './types/Panels';
import { getQuotedMessageSelector } from './state/selectors/composer';
import { flushAttachmentDownloadQueue } from './util/attachmentDownloadQueue';
import { StartupQueue } from './util/StartupQueue';
import { showConfirmationDialog } from './util/showConfirmationDialog';
@ -191,7 +187,7 @@ import { RetryPlaceholders } from './util/retryPlaceholders';
import { setBatchingStrategy } from './util/messageBatcher';
import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration';
import { makeLookup } from './util/makeLookup';
import { focusableSelectors } from './util/focusableSelectors';
import { addGlobalKeyboardShortcuts } from './services/addGlobalKeyboardShortcuts';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@ -1312,532 +1308,7 @@ export async function startApp(): Promise<void> {
window.reduxActions.user.userChanged({ menuOptions: options });
});
document.addEventListener('keydown', event => {
const { ctrlKey, metaKey, shiftKey, altKey } = event;
const commandKey = window.platform === 'darwin' && metaKey;
const controlKey = window.platform !== 'darwin' && ctrlKey;
const commandOrCtrl = commandKey || controlKey;
const state = store.getState();
const selectedId = state.conversations.selectedConversationId;
const conversation = window.ConversationController.get(selectedId);
const key = KeyboardLayout.lookup(event);
// NAVIGATION
// Show keyboard shortcuts - handled by Electron-managed keyboard shortcuts
// However, on linux Ctrl+/ selects all text, so we prevent that
if (commandOrCtrl && !altKey && key === '/') {
window.Events.showKeyboardShortcuts();
event.stopPropagation();
event.preventDefault();
return;
}
// Super tab :)
if (
(commandOrCtrl && key === 'F6') ||
(commandOrCtrl && !shiftKey && (key === 't' || key === 'T'))
) {
window.enterKeyboardMode();
const focusedElement = document.activeElement;
const targets: Array<HTMLElement> = Array.from(
document.querySelectorAll('[data-supertab="true"]')
);
const focusedIndex = targets.findIndex(target => {
if (!target || !focusedElement) {
return false;
}
if (target === focusedElement) {
return true;
}
if (target.contains(focusedElement)) {
return true;
}
return false;
});
const lastIndex = targets.length - 1;
const increment = shiftKey ? -1 : 1;
let index;
if (focusedIndex < 0 || focusedIndex >= lastIndex) {
index = 0;
} else {
index = focusedIndex + increment;
}
while (!targets[index]) {
index += increment;
if (index > lastIndex || index < 0) {
index = 0;
}
}
const node = targets[index];
const firstFocusableElement = node.querySelectorAll<HTMLElement>(
focusableSelectors.join(',')
)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
} else {
const nodeInfo = Array.from(node.attributes)
.map(attr => `${attr.name}=${attr.value}`)
.join(',');
log.warn(
`supertab: could not find focus for DOM node ${node.nodeName}<${nodeInfo}>`
);
window.enterMouseMode();
const { activeElement } = document;
if (
activeElement &&
'blur' in activeElement &&
typeof activeElement.blur === 'function'
) {
activeElement.blur();
}
}
}
// Cancel out of keyboard shortcut screen - has first precedence
const isShortcutGuideModalVisible = window.reduxStore
? window.reduxStore.getState().globalModals.isShortcutGuideModalVisible
: false;
if (isShortcutGuideModalVisible && key === 'Escape') {
window.reduxActions.globalModals.closeShortcutGuideModal();
event.preventDefault();
event.stopPropagation();
return;
}
// Escape is heavily overloaded - here we avoid clashes with other Escape handlers
if (key === 'Escape') {
// Check origin - if within a react component which handles escape, don't handle.
// Why? Because React's synthetic events can cause events to be handled twice.
const target = document.activeElement;
// We might want to use NamedNodeMap.getNamedItem('class')
/* eslint-disable @typescript-eslint/no-explicit-any */
if (
target &&
target.attributes &&
(target.attributes as any).class &&
(target.attributes as any).class.value
) {
const className = (target.attributes as any).class.value;
/* eslint-enable @typescript-eslint/no-explicit-any */
// Search box wants to handle events internally
if (className.includes('LeftPaneSearchInput__input')) {
return;
}
}
// These add listeners to document, but we'll run first
const confirmationModal = document.querySelector(
'.module-confirmation-dialog__overlay'
);
if (confirmationModal) {
return;
}
const emojiPicker = document.querySelector('.module-emoji-picker');
if (emojiPicker) {
return;
}
const lightBox = document.querySelector('.Lightbox');
if (lightBox) {
return;
}
const stickerPicker = document.querySelector('.module-sticker-picker');
if (stickerPicker) {
return;
}
const stickerPreview = document.querySelector(
'.module-sticker-manager__preview-modal__overlay'
);
if (stickerPreview) {
return;
}
const reactionViewer = document.querySelector(
'.module-reaction-viewer'
);
if (reactionViewer) {
return;
}
const reactionPicker = document.querySelector('.module-ReactionPicker');
if (reactionPicker) {
return;
}
const contactModal = document.querySelector('.module-contact-modal');
if (contactModal) {
return;
}
const modalHost = document.querySelector('.module-modal-host__overlay');
if (modalHost) {
return;
}
}
// Send Escape to active conversation so it can close panels
if (conversation && key === 'Escape') {
window.reduxActions.conversations.popPanelForConversation();
event.preventDefault();
event.stopPropagation();
return;
}
// Preferences - handled by Electron-managed keyboard shortcuts
// Open the top-right menu for current conversation
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'l' || key === 'L')
) {
const button = document.querySelector(
'.module-ConversationHeader__button--more'
);
if (!button) {
return;
}
// Because the menu is shown at a location based on the initiating click, we need
// to fake up a mouse event to get the menu to show somewhere other than (0,0).
const { x, y, width, height } = button.getBoundingClientRect();
const mouseEvent = document.createEvent('MouseEvents');
// Types do not match signature
/* eslint-disable @typescript-eslint/no-explicit-any */
mouseEvent.initMouseEvent(
'click',
true, // bubbles
false, // cancelable
null as any, // view
null as any, // detail
0, // screenX,
0, // screenY,
x + width / 2,
y + height / 2,
false, // ctrlKey,
false, // altKey,
false, // shiftKey,
false, // metaKey,
false as any, // button,
document.body
);
/* eslint-enable @typescript-eslint/no-explicit-any */
button.dispatchEvent(mouseEvent);
event.preventDefault();
event.stopPropagation();
return;
}
// Focus composer field
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 't' || key === 'T')
) {
window.reduxActions.composer.setComposerFocus(conversation.id);
event.preventDefault();
event.stopPropagation();
return;
}
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'j' || key === 'J')
) {
window.enterKeyboardMode();
const item: HTMLElement | null =
document.querySelector(
'.module-last-seen-indicator ~ div .module-message'
) ||
document.querySelector(
'.module-timeline__last-message .module-message'
);
item?.focus();
}
// Open all media
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'm' || key === 'M')
) {
window.reduxActions.conversations.pushPanelForConversation({
type: PanelType.AllMedia,
});
event.preventDefault();
event.stopPropagation();
return;
}
// Open emoji picker - handled by component
// Open sticker picker - handled by component
// Begin recording voice note - handled by component
// Archive or unarchive conversation
if (
conversation &&
!conversation.get('isArchived') &&
commandOrCtrl &&
shiftKey &&
(key === 'a' || key === 'A')
) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.conversations.onArchive(conversation.id);
// It's very likely that the act of archiving a conversation will set focus to
// 'none,' or the top-level body element. This resets it to the left pane.
if (document.activeElement === document.body) {
const leftPaneEl: HTMLElement | null = document.querySelector(
'.module-left-pane__list'
);
if (leftPaneEl) {
leftPaneEl.focus();
}
}
return;
}
if (
conversation &&
conversation.get('isArchived') &&
commandOrCtrl &&
shiftKey &&
(key === 'u' || key === 'U')
) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.conversations.onMoveToInbox(conversation.id);
return;
}
// Scroll to bottom of list - handled by component
// Scroll to top of list - handled by component
// Close conversation
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'c' || key === 'C')
) {
event.preventDefault();
event.stopPropagation();
onConversationClosed(conversation.id, 'keyboard shortcut close');
window.reduxActions.conversations.showConversation({
conversationId: undefined,
messageId: undefined,
});
return;
}
// MESSAGES
// Show message details
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'd' || key === 'D')
) {
event.preventDefault();
event.stopPropagation();
const { targetedMessage } = state.conversations;
if (!targetedMessage) {
return;
}
window.reduxActions.conversations.pushPanelForConversation({
type: PanelType.MessageDetails,
args: {
messageId: targetedMessage,
},
});
return;
}
// Toggle reply to message
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'r' || key === 'R')
) {
event.preventDefault();
event.stopPropagation();
const { targetedMessage } = state.conversations;
const quotedMessageSelector = getQuotedMessageSelector(state);
const quote = quotedMessageSelector(conversation.id);
window.reduxActions.composer.setQuoteByMessageId(
conversation.id,
quote ? undefined : targetedMessage
);
return;
}
// Save attachment
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 's' || key === 'S')
) {
event.preventDefault();
event.stopPropagation();
const { targetedMessage } = state.conversations;
if (targetedMessage) {
window.reduxActions.conversations.saveAttachmentFromMessage(
targetedMessage
);
return;
}
}
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'd' || key === 'D')
) {
const { forwardMessagesProps } = state.globalModals;
const { targetedMessage, selectedMessageIds } = state.conversations;
const messageIds =
selectedMessageIds ??
(targetedMessage != null ? [targetedMessage] : null);
if (forwardMessagesProps == null && messageIds != null) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.globalModals.toggleDeleteMessagesModal({
conversationId: conversation.id,
messageIds,
onDelete() {
if (selectedMessageIds != null) {
window.reduxActions.conversations.toggleSelectMode(false);
}
},
});
return;
}
}
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 's' || key === 'S')
) {
const { hasConfirmationModal } = state.globalModals;
const { targetedMessage, selectedMessageIds } = state.conversations;
const messageIds =
selectedMessageIds ??
(targetedMessage != null ? [targetedMessage] : null);
if (!hasConfirmationModal && messageIds != null) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.globalModals.toggleForwardMessagesModal(
messageIds,
() => {
if (selectedMessageIds != null) {
window.reduxActions.conversations.toggleSelectMode(false);
}
}
);
return;
}
}
// COMPOSER
// Create a newline in your message - handled by component
// Expand composer - handled by component
// Send in expanded composer - handled by component
// Attach file
// hooks/useKeyboardShorcuts useAttachFileShortcut
// Remove draft link preview
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'p' || key === 'P')
) {
removeLinkPreview(conversation.id);
event.preventDefault();
event.stopPropagation();
return;
}
// Attach file
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'p' || key === 'P')
) {
void clearConversationDraftAttachments(
conversation.id,
conversation.get('draftAttachments')
);
event.preventDefault();
event.stopPropagation();
// Commented out because this is the last item
// return;
}
});
addGlobalKeyboardShortcuts();
}
window.Whisper.events.on('setupAsNewDevice', () => {

View file

@ -59,6 +59,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
removeAttachment: action('removeAttachment'),
theme: React.useContext(StorybookThemeContext),
setComposerFocus: action('setComposerFocus'),
setMessageToEdit: action('setMessageToEdit'),
setQuoteByMessageId: action('setQuoteByMessageId'),
showToast: action('showToast'),

View file

@ -53,6 +53,7 @@ import { Quote } from './conversation/Quote';
import { countStickers } from './stickers/lib';
import {
useAttachFileShortcut,
useEditLastMessageSent,
useKeyboardShortcuts,
} from '../hooks/useKeyboardShortcuts';
import { MediaEditor } from './MediaEditor';
@ -103,6 +104,7 @@ export type OwnProps = Readonly<{
isGroupV1AndDisabled?: boolean;
isMissingMandatoryProfileSharing?: boolean;
isSignalConversation?: boolean;
lastEditableMessageId?: string;
recordingState: RecordingState;
messageCompositionId: string;
shouldHidePopovers?: boolean;
@ -157,6 +159,7 @@ export type OwnProps = Readonly<{
removeAttachment: (conversationId: string, filePath: string) => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
setComposerFocus: (conversationId: string) => unknown;
setMessageToEdit(conversationId: string, messageId: string): unknown;
setQuoteByMessageId(
conversationId: string,
messageId: string | undefined
@ -225,6 +228,7 @@ export function CompositionArea({
imageToBlurHash,
isDisabled,
isSignalConversation,
lastEditableMessageId,
messageCompositionId,
pushPanelForConversation,
platform,
@ -233,6 +237,7 @@ export function CompositionArea({
sendEditedMessage,
sendMultiMediaMessage,
setComposerFocus,
setMessageToEdit,
setQuoteByMessageId,
shouldHidePopovers,
showToast,
@ -394,8 +399,26 @@ export function CompositionArea({
setAttachmentToEdit(attachment);
}
const isComposerEmpty =
!draftAttachments.length && !draftText && !draftEditMessage;
const maybeEditMessage = useCallback(() => {
if (!isComposerEmpty || !lastEditableMessageId) {
return false;
}
setMessageToEdit(conversationId, lastEditableMessageId);
return true;
}, [
conversationId,
isComposerEmpty,
lastEditableMessageId,
setMessageToEdit,
]);
const attachFileShortcut = useAttachFileShortcut(launchAttachmentPicker);
useKeyboardShortcuts(attachFileShortcut);
const editLastMessageSent = useEditLastMessageSent(maybeEditMessage);
useKeyboardShortcuts(attachFileShortcut, editLastMessageSent);
// Focus input on first mount
const previousFocusCounter = usePrevious<number | undefined>(
@ -495,8 +518,7 @@ export function CompositionArea({
setLarge(l => !l);
}, [setLarge]);
const shouldShowMicrophone =
!large && !draftAttachments.length && !draftText && !draftEditMessage;
const shouldShowMicrophone = !large && isComposerEmpty;
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);

View file

@ -256,6 +256,11 @@ function getComposerShortcuts(
description: i18n('icu:Keyboard--remove-draft-attachments'),
keys: [['commandOrCtrl', 'shift', 'P']],
},
{
id: 'Keyboard--edit-last-message',
description: i18n('icu:Keyboard--edit-last-message'),
keys: [['↑']],
},
];
if (isFormattingFlagEnabled) {

View file

@ -181,6 +181,29 @@ export function useToggleReactionPicker(
);
}
export function useEditLastMessageSent(
maybeEditMessage: () => boolean
): KeyboardShortcutHandlerType {
return useCallback(
ev => {
const key = KeyboardLayout.lookup(ev);
if (key === 'ArrowUp') {
const value = maybeEditMessage();
if (value) {
ev.preventDefault();
ev.stopPropagation();
}
return value;
}
return false;
},
[maybeEditMessage]
);
}
export function useKeyboardShortcuts(
...eventHandlers: Array<KeyboardShortcutHandlerType>
): void {

View file

@ -0,0 +1,545 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as KeyboardLayout from './keyboardLayout';
import * as log from '../logging/log';
import { PanelType } from '../types/Panels';
import { clearConversationDraftAttachments } from '../util/clearConversationDraftAttachments';
import { drop } from '../util/drop';
import { focusableSelectors } from '../util/focusableSelectors';
import { getQuotedMessageSelector } from '../state/selectors/composer';
import { removeLinkPreview } from './LinkPreview';
export function addGlobalKeyboardShortcuts(): void {
document.addEventListener('keydown', event => {
const { ctrlKey, metaKey, shiftKey, altKey } = event;
const commandKey = window.platform === 'darwin' && metaKey;
const controlKey = window.platform !== 'darwin' && ctrlKey;
const commandOrCtrl = commandKey || controlKey;
const state = window.reduxStore.getState();
const { selectedConversationId } = state.conversations;
const conversation = window.ConversationController.get(
selectedConversationId
);
const key = KeyboardLayout.lookup(event);
// NAVIGATION
// Show keyboard shortcuts - handled by Electron-managed keyboard shortcuts
// However, on linux Ctrl+/ selects all text, so we prevent that
if (commandOrCtrl && !altKey && key === '/') {
window.Events.showKeyboardShortcuts();
event.stopPropagation();
event.preventDefault();
return;
}
// Super tab :)
if (
(commandOrCtrl && key === 'F6') ||
(commandOrCtrl && !shiftKey && (key === 't' || key === 'T'))
) {
window.enterKeyboardMode();
const focusedElement = document.activeElement;
const targets: Array<HTMLElement> = Array.from(
document.querySelectorAll('[data-supertab="true"]')
);
const focusedIndex = targets.findIndex(target => {
if (!target || !focusedElement) {
return false;
}
if (target === focusedElement) {
return true;
}
if (target.contains(focusedElement)) {
return true;
}
return false;
});
const lastIndex = targets.length - 1;
const increment = shiftKey ? -1 : 1;
let index;
if (focusedIndex < 0 || focusedIndex >= lastIndex) {
index = 0;
} else {
index = focusedIndex + increment;
}
while (!targets[index]) {
index += increment;
if (index > lastIndex || index < 0) {
index = 0;
}
}
const node = targets[index];
const firstFocusableElement = node.querySelectorAll<HTMLElement>(
focusableSelectors.join(',')
)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
} else {
const nodeInfo = Array.from(node.attributes)
.map(attr => `${attr.name}=${attr.value}`)
.join(',');
log.warn(
`supertab: could not find focus for DOM node ${node.nodeName}<${nodeInfo}>`
);
window.enterMouseMode();
const { activeElement } = document;
if (
activeElement &&
'blur' in activeElement &&
typeof activeElement.blur === 'function'
) {
activeElement.blur();
}
}
}
// Cancel out of keyboard shortcut screen - has first precedence
const isShortcutGuideModalVisible = window.reduxStore
? window.reduxStore.getState().globalModals.isShortcutGuideModalVisible
: false;
if (isShortcutGuideModalVisible && key === 'Escape') {
window.reduxActions.globalModals.closeShortcutGuideModal();
event.preventDefault();
event.stopPropagation();
return;
}
// Escape is heavily overloaded - here we avoid clashes with other Escape handlers
if (key === 'Escape') {
// Check origin - if within a react component which handles escape, don't handle.
// Why? Because React's synthetic events can cause events to be handled twice.
const target = document.activeElement;
// We might want to use NamedNodeMap.getNamedItem('class')
/* eslint-disable @typescript-eslint/no-explicit-any */
if (
target &&
target.attributes &&
(target.attributes as any).class &&
(target.attributes as any).class.value
) {
const className = (target.attributes as any).class.value;
/* eslint-enable @typescript-eslint/no-explicit-any */
// Search box wants to handle events internally
if (className.includes('LeftPaneSearchInput__input')) {
return;
}
}
// These add listeners to document, but we'll run first
const confirmationModal = document.querySelector(
'.module-confirmation-dialog__overlay'
);
if (confirmationModal) {
return;
}
const emojiPicker = document.querySelector('.module-emoji-picker');
if (emojiPicker) {
return;
}
const lightBox = document.querySelector('.Lightbox');
if (lightBox) {
return;
}
const stickerPicker = document.querySelector('.module-sticker-picker');
if (stickerPicker) {
return;
}
const stickerPreview = document.querySelector(
'.module-sticker-manager__preview-modal__overlay'
);
if (stickerPreview) {
return;
}
const reactionViewer = document.querySelector('.module-reaction-viewer');
if (reactionViewer) {
return;
}
const reactionPicker = document.querySelector('.module-ReactionPicker');
if (reactionPicker) {
return;
}
const contactModal = document.querySelector('.module-contact-modal');
if (contactModal) {
return;
}
const modalHost = document.querySelector('.module-modal-host__overlay');
if (modalHost) {
return;
}
}
// Send Escape to active conversation so it can close panels
if (conversation && key === 'Escape') {
window.reduxActions.conversations.popPanelForConversation();
event.preventDefault();
event.stopPropagation();
return;
}
// Preferences - handled by Electron-managed keyboard shortcuts
// Open the top-right menu for current conversation
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'l' || key === 'L')
) {
const button = document.querySelector(
'.module-ConversationHeader__button--more'
);
if (!button) {
return;
}
// Because the menu is shown at a location based on the initiating click, we need
// to fake up a mouse event to get the menu to show somewhere other than (0,0).
const { x, y, width, height } = button.getBoundingClientRect();
const mouseEvent = document.createEvent('MouseEvents');
// Types do not match signature
/* eslint-disable @typescript-eslint/no-explicit-any */
mouseEvent.initMouseEvent(
'click',
true, // bubbles
false, // cancelable
null as any, // view
null as any, // detail
0, // screenX,
0, // screenY,
x + width / 2,
y + height / 2,
false, // ctrlKey,
false, // altKey,
false, // shiftKey,
false, // metaKey,
false as any, // button,
document.body
);
/* eslint-enable @typescript-eslint/no-explicit-any */
button.dispatchEvent(mouseEvent);
event.preventDefault();
event.stopPropagation();
return;
}
// Focus composer field
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 't' || key === 'T')
) {
window.reduxActions.composer.setComposerFocus(conversation.id);
event.preventDefault();
event.stopPropagation();
return;
}
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'j' || key === 'J')
) {
window.enterKeyboardMode();
const item: HTMLElement | null =
document.querySelector(
'.module-last-seen-indicator ~ div .module-message'
) ||
document.querySelector(
'.module-timeline__last-message .module-message'
);
item?.focus();
}
// Open all media
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'm' || key === 'M')
) {
window.reduxActions.conversations.pushPanelForConversation({
type: PanelType.AllMedia,
});
event.preventDefault();
event.stopPropagation();
return;
}
// Open emoji picker - handled by component
// Open sticker picker - handled by component
// Begin recording voice note - handled by component
// Archive or unarchive conversation
if (
conversation &&
!conversation.get('isArchived') &&
commandOrCtrl &&
shiftKey &&
(key === 'a' || key === 'A')
) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.conversations.onArchive(conversation.id);
// It's very likely that the act of archiving a conversation will set focus to
// 'none,' or the top-level body element. This resets it to the left pane.
if (document.activeElement === document.body) {
const leftPaneEl: HTMLElement | null = document.querySelector(
'.module-left-pane__list'
);
if (leftPaneEl) {
leftPaneEl.focus();
}
}
return;
}
if (
conversation &&
conversation.get('isArchived') &&
commandOrCtrl &&
shiftKey &&
(key === 'u' || key === 'U')
) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.conversations.onMoveToInbox(conversation.id);
return;
}
// Scroll to bottom of list - handled by component
// Scroll to top of list - handled by component
// Close conversation
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'c' || key === 'C')
) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.conversations.onConversationClosed(
conversation.id,
'keyboard shortcut close'
);
window.reduxActions.conversations.showConversation({
conversationId: undefined,
messageId: undefined,
});
return;
}
// MESSAGES
// Show message details
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'd' || key === 'D')
) {
event.preventDefault();
event.stopPropagation();
const { targetedMessage } = state.conversations;
if (!targetedMessage) {
return;
}
window.reduxActions.conversations.pushPanelForConversation({
type: PanelType.MessageDetails,
args: {
messageId: targetedMessage,
},
});
return;
}
// Toggle reply to message
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'r' || key === 'R')
) {
event.preventDefault();
event.stopPropagation();
const { targetedMessage } = state.conversations;
const quotedMessageSelector = getQuotedMessageSelector(state);
const quote = quotedMessageSelector(conversation.id);
window.reduxActions.composer.setQuoteByMessageId(
conversation.id,
quote ? undefined : targetedMessage
);
return;
}
// Save attachment
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 's' || key === 'S')
) {
event.preventDefault();
event.stopPropagation();
const { targetedMessage } = state.conversations;
if (targetedMessage) {
window.reduxActions.conversations.saveAttachmentFromMessage(
targetedMessage
);
return;
}
}
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'd' || key === 'D')
) {
const { forwardMessagesProps } = state.globalModals;
const { targetedMessage, selectedMessageIds } = state.conversations;
const messageIds =
selectedMessageIds ??
(targetedMessage != null ? [targetedMessage] : null);
if (forwardMessagesProps == null && messageIds != null) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.globalModals.toggleDeleteMessagesModal({
conversationId: conversation.id,
messageIds,
onDelete() {
if (selectedMessageIds != null) {
window.reduxActions.conversations.toggleSelectMode(false);
}
},
});
return;
}
}
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 's' || key === 'S')
) {
const { hasConfirmationModal } = state.globalModals;
const { targetedMessage, selectedMessageIds } = state.conversations;
const messageIds =
selectedMessageIds ??
(targetedMessage != null ? [targetedMessage] : null);
if (!hasConfirmationModal && messageIds != null) {
event.preventDefault();
event.stopPropagation();
window.reduxActions.globalModals.toggleForwardMessagesModal(
messageIds,
() => {
if (selectedMessageIds != null) {
window.reduxActions.conversations.toggleSelectMode(false);
}
}
);
return;
}
}
// COMPOSER
// Create a newline in your message - handled by component
// Expand composer - handled by component
// Send in expanded composer - handled by component
// Attach file
// hooks/useKeyboardShorcuts useAttachFileShortcut
// Remove draft link preview
if (
conversation &&
commandOrCtrl &&
!shiftKey &&
(key === 'p' || key === 'P')
) {
removeLinkPreview(conversation.id);
event.preventDefault();
event.stopPropagation();
return;
}
// Attach file
if (
conversation &&
commandOrCtrl &&
shiftKey &&
(key === 'p' || key === 'P')
) {
drop(
clearConversationDraftAttachments(
conversation.id,
conversation.get('draftAttachments')
)
);
event.preventDefault();
event.stopPropagation();
// Commented out because this is the last item
// return;
}
});
}

View file

@ -161,6 +161,7 @@ import {
import { ReceiptType } from '../../types/Receipt';
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
import { Sound, SoundType } from '../../util/Sound';
import { canEditMessage } from '../../util/canEditMessage';
// State
@ -1763,7 +1764,7 @@ function setMessageToEdit(
return;
}
if (!message.body) {
if (!canEditMessage(message) || !message.body) {
return;
}

View file

@ -61,6 +61,8 @@ import { getConversationTitleForPanelType } from '../../util/getConversationTitl
import type { PanelRenderType } from '../../types/Panels';
import type { HasStories } from '../../types/Stories';
import { getHasStoriesSelector } from './stories2';
import { canEditMessage } from '../../util/canEditMessage';
import { isOutgoing } from '../../messages/helpers';
export type ConversationWithStoriesType = ConversationType & {
hasStories?: HasStories;
@ -250,6 +252,17 @@ export const getMessagesByConversation = createSelector(
}
);
export const getConversationMessages = createSelector(
getSelectedConversationId,
getMessagesByConversation,
(
conversationId,
messagesByConversation
): ConversationMessageType | undefined => {
return conversationId ? messagesByConversation[conversationId] : undefined;
}
);
const collator = new Intl.Collator();
// Note: we will probably want to put i18n and regionCode back when we are formatting
@ -1127,3 +1140,28 @@ export const getConversationTitle = createSelector(
(i18n, panel): string | undefined =>
getConversationTitleForPanelType(i18n, panel?.type)
);
export const getLastEditableMessageId = createSelector(
getConversationMessages,
getMessages,
(conversationMessages, messagesLookup): string | undefined => {
if (!conversationMessages) {
return;
}
for (let i = conversationMessages.messageIds.length - 1; i >= 0; i -= 1) {
const messageId = conversationMessages.messageIds[i];
const message = messagesLookup[messageId];
if (!message) {
continue;
}
if (isOutgoing(message)) {
return canEditMessage(message) ? message.id : undefined;
}
}
return undefined;
}
);

View file

@ -68,7 +68,7 @@ import { isNotNil } from '../../util/isNotNil';
import { isMoreRecentThan } from '../../util/timestamp';
import * as iterables from '../../util/iterables';
import { strictAssert } from '../../util/assert';
import { canEditMessages } from '../../util/canEditMessages';
import { canEditMessage } from '../../util/canEditMessage';
import { getAccountSelector } from './accounts';
import {
@ -130,7 +130,6 @@ import { getTitleNoDefault, getNumber } from '../../util/getTitle';
export { isIncoming, isOutgoing, isStory };
const MAX_EDIT_COUNT = 10;
const THREE_HOURS = 3 * HOUR;
const linkify = LinkifyIt();
@ -1822,18 +1821,6 @@ export function canRetryDeleteForEveryone(
);
}
export function canEditMessage(message: MessageWithUIFieldsType): boolean {
return (
canEditMessages() &&
!message.deletedForEveryone &&
isOutgoing(message) &&
isMoreRecentThan(message.sent_at, THREE_HOURS) &&
(message.editHistory?.length ?? 0) <= MAX_EDIT_COUNT &&
someSendStatus(message.sendStateByConversationId, isSent) &&
Boolean(message.body)
);
}
export function canDownload(
message: MessageWithUIFieldsType,
conversationSelector: GetConversationByIdType

View file

@ -25,6 +25,7 @@ import { getEmojiSkinTone, getTextFormattingEnabled } from '../selectors/items';
import {
getConversationSelector,
getGroupAdminsSelector,
getLastEditableMessageId,
getSelectedMessageIds,
getTargetedConversationsPanelsCount,
isMissingRequiredProfileSharing,
@ -126,6 +127,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const isFormattingSpoilersFlagEnabled =
getIsFormattingSpoilersFlagEnabled(state);
const lastEditableMessageId = getLastEditableMessageId(state);
return {
// Base
conversationId: id,
@ -137,6 +140,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
isFormattingEnabled,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
lastEditableMessageId,
messageCompositionId,
platform,
sendCounter,

24
ts/util/canEditMessage.ts Normal file
View file

@ -0,0 +1,24 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d';
import { HOUR } from './durations';
import { canEditMessages } from './canEditMessages';
import { isMoreRecentThan } from './timestamp';
import { isOutgoing } from '../messages/helpers';
import { isSent, someSendStatus } from '../messages/MessageSendState';
const MAX_EDIT_COUNT = 10;
const THREE_HOURS = 3 * HOUR;
export function canEditMessage(message: MessageAttributesType): boolean {
return (
canEditMessages() &&
!message.deletedForEveryone &&
isOutgoing(message) &&
isMoreRecentThan(message.sent_at, THREE_HOURS) &&
(message.editHistory?.length ?? 0) <= MAX_EDIT_COUNT &&
someSendStatus(message.sendStateByConversationId, isSent) &&
Boolean(message.body)
);
}

View file

@ -13,7 +13,7 @@ import { ErrorWithToast } from '../types/ErrorWithToast';
import { SendStatus } from '../messages/MessageSendState';
import { ToastType } from '../types/Toast';
import { UUID } from '../types/UUID';
import { canEditMessage } from '../state/selectors/message';
import { canEditMessage } from './canEditMessage';
import {
conversationJobQueue,
conversationQueueJobEnum,