Adds keyboard shortcut for editing last message sent
This commit is contained in:
parent
a1fd4e55ee
commit
216ee67c50
13 changed files with 675 additions and 550 deletions
|
@ -5454,6 +5454,10 @@
|
||||||
"messageformat": "Jump to chat",
|
"messageformat": "Jump to chat",
|
||||||
"description": "A shortcut allowing direct navigation to conversations 1 to 9 in list"
|
"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": {
|
"Keyboard--Key--ctrl": {
|
||||||
"message": "Ctrl",
|
"message": "Ctrl",
|
||||||
"description": "(deleted 03/29/2023) Key shown in shortcut combination in shortcuts guide"
|
"description": "(deleted 03/29/2023) Key shown in shortcut combination in shortcuts guide"
|
||||||
|
|
533
ts/background.ts
533
ts/background.ts
|
@ -171,10 +171,6 @@ import type AccountManager from './textsecure/AccountManager';
|
||||||
import { onStoryRecipientUpdate } from './util/onStoryRecipientUpdate';
|
import { onStoryRecipientUpdate } from './util/onStoryRecipientUpdate';
|
||||||
import { StoryViewModeType, StoryViewTargetType } from './types/Stories';
|
import { StoryViewModeType, StoryViewTargetType } from './types/Stories';
|
||||||
import { downloadOnboardingStory } from './util/downloadOnboardingStory';
|
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 { flushAttachmentDownloadQueue } from './util/attachmentDownloadQueue';
|
||||||
import { StartupQueue } from './util/StartupQueue';
|
import { StartupQueue } from './util/StartupQueue';
|
||||||
import { showConfirmationDialog } from './util/showConfirmationDialog';
|
import { showConfirmationDialog } from './util/showConfirmationDialog';
|
||||||
|
@ -191,7 +187,7 @@ import { RetryPlaceholders } from './util/retryPlaceholders';
|
||||||
import { setBatchingStrategy } from './util/messageBatcher';
|
import { setBatchingStrategy } from './util/messageBatcher';
|
||||||
import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration';
|
import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration';
|
||||||
import { makeLookup } from './util/makeLookup';
|
import { makeLookup } from './util/makeLookup';
|
||||||
import { focusableSelectors } from './util/focusableSelectors';
|
import { addGlobalKeyboardShortcuts } from './services/addGlobalKeyboardShortcuts';
|
||||||
|
|
||||||
export function isOverHourIntoPast(timestamp: number): boolean {
|
export function isOverHourIntoPast(timestamp: number): boolean {
|
||||||
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
||||||
|
@ -1312,532 +1308,7 @@ export async function startApp(): Promise<void> {
|
||||||
window.reduxActions.user.userChanged({ menuOptions: options });
|
window.reduxActions.user.userChanged({ menuOptions: options });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', event => {
|
addGlobalKeyboardShortcuts();
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Whisper.events.on('setupAsNewDevice', () => {
|
window.Whisper.events.on('setupAsNewDevice', () => {
|
||||||
|
|
|
@ -59,6 +59,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
removeAttachment: action('removeAttachment'),
|
removeAttachment: action('removeAttachment'),
|
||||||
theme: React.useContext(StorybookThemeContext),
|
theme: React.useContext(StorybookThemeContext),
|
||||||
setComposerFocus: action('setComposerFocus'),
|
setComposerFocus: action('setComposerFocus'),
|
||||||
|
setMessageToEdit: action('setMessageToEdit'),
|
||||||
setQuoteByMessageId: action('setQuoteByMessageId'),
|
setQuoteByMessageId: action('setQuoteByMessageId'),
|
||||||
showToast: action('showToast'),
|
showToast: action('showToast'),
|
||||||
|
|
||||||
|
|
|
@ -53,6 +53,7 @@ import { Quote } from './conversation/Quote';
|
||||||
import { countStickers } from './stickers/lib';
|
import { countStickers } from './stickers/lib';
|
||||||
import {
|
import {
|
||||||
useAttachFileShortcut,
|
useAttachFileShortcut,
|
||||||
|
useEditLastMessageSent,
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
} from '../hooks/useKeyboardShortcuts';
|
} from '../hooks/useKeyboardShortcuts';
|
||||||
import { MediaEditor } from './MediaEditor';
|
import { MediaEditor } from './MediaEditor';
|
||||||
|
@ -103,6 +104,7 @@ export type OwnProps = Readonly<{
|
||||||
isGroupV1AndDisabled?: boolean;
|
isGroupV1AndDisabled?: boolean;
|
||||||
isMissingMandatoryProfileSharing?: boolean;
|
isMissingMandatoryProfileSharing?: boolean;
|
||||||
isSignalConversation?: boolean;
|
isSignalConversation?: boolean;
|
||||||
|
lastEditableMessageId?: string;
|
||||||
recordingState: RecordingState;
|
recordingState: RecordingState;
|
||||||
messageCompositionId: string;
|
messageCompositionId: string;
|
||||||
shouldHidePopovers?: boolean;
|
shouldHidePopovers?: boolean;
|
||||||
|
@ -157,6 +159,7 @@ export type OwnProps = Readonly<{
|
||||||
removeAttachment: (conversationId: string, filePath: string) => unknown;
|
removeAttachment: (conversationId: string, filePath: string) => unknown;
|
||||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||||
setComposerFocus: (conversationId: string) => unknown;
|
setComposerFocus: (conversationId: string) => unknown;
|
||||||
|
setMessageToEdit(conversationId: string, messageId: string): unknown;
|
||||||
setQuoteByMessageId(
|
setQuoteByMessageId(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string | undefined
|
messageId: string | undefined
|
||||||
|
@ -225,6 +228,7 @@ export function CompositionArea({
|
||||||
imageToBlurHash,
|
imageToBlurHash,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
isSignalConversation,
|
isSignalConversation,
|
||||||
|
lastEditableMessageId,
|
||||||
messageCompositionId,
|
messageCompositionId,
|
||||||
pushPanelForConversation,
|
pushPanelForConversation,
|
||||||
platform,
|
platform,
|
||||||
|
@ -233,6 +237,7 @@ export function CompositionArea({
|
||||||
sendEditedMessage,
|
sendEditedMessage,
|
||||||
sendMultiMediaMessage,
|
sendMultiMediaMessage,
|
||||||
setComposerFocus,
|
setComposerFocus,
|
||||||
|
setMessageToEdit,
|
||||||
setQuoteByMessageId,
|
setQuoteByMessageId,
|
||||||
shouldHidePopovers,
|
shouldHidePopovers,
|
||||||
showToast,
|
showToast,
|
||||||
|
@ -394,8 +399,26 @@ export function CompositionArea({
|
||||||
setAttachmentToEdit(attachment);
|
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);
|
const attachFileShortcut = useAttachFileShortcut(launchAttachmentPicker);
|
||||||
useKeyboardShortcuts(attachFileShortcut);
|
const editLastMessageSent = useEditLastMessageSent(maybeEditMessage);
|
||||||
|
useKeyboardShortcuts(attachFileShortcut, editLastMessageSent);
|
||||||
|
|
||||||
// Focus input on first mount
|
// Focus input on first mount
|
||||||
const previousFocusCounter = usePrevious<number | undefined>(
|
const previousFocusCounter = usePrevious<number | undefined>(
|
||||||
|
@ -495,8 +518,7 @@ export function CompositionArea({
|
||||||
setLarge(l => !l);
|
setLarge(l => !l);
|
||||||
}, [setLarge]);
|
}, [setLarge]);
|
||||||
|
|
||||||
const shouldShowMicrophone =
|
const shouldShowMicrophone = !large && isComposerEmpty;
|
||||||
!large && !draftAttachments.length && !draftText && !draftEditMessage;
|
|
||||||
|
|
||||||
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
|
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
|
||||||
|
|
||||||
|
|
|
@ -256,6 +256,11 @@ function getComposerShortcuts(
|
||||||
description: i18n('icu:Keyboard--remove-draft-attachments'),
|
description: i18n('icu:Keyboard--remove-draft-attachments'),
|
||||||
keys: [['commandOrCtrl', 'shift', 'P']],
|
keys: [['commandOrCtrl', 'shift', 'P']],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'Keyboard--edit-last-message',
|
||||||
|
description: i18n('icu:Keyboard--edit-last-message'),
|
||||||
|
keys: [['↑']],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isFormattingFlagEnabled) {
|
if (isFormattingFlagEnabled) {
|
||||||
|
|
|
@ -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(
|
export function useKeyboardShortcuts(
|
||||||
...eventHandlers: Array<KeyboardShortcutHandlerType>
|
...eventHandlers: Array<KeyboardShortcutHandlerType>
|
||||||
): void {
|
): void {
|
||||||
|
|
545
ts/services/addGlobalKeyboardShortcuts.ts
Normal file
545
ts/services/addGlobalKeyboardShortcuts.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -161,6 +161,7 @@ import {
|
||||||
import { ReceiptType } from '../../types/Receipt';
|
import { ReceiptType } from '../../types/Receipt';
|
||||||
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
|
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
|
||||||
import { Sound, SoundType } from '../../util/Sound';
|
import { Sound, SoundType } from '../../util/Sound';
|
||||||
|
import { canEditMessage } from '../../util/canEditMessage';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -1763,7 +1764,7 @@ function setMessageToEdit(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message.body) {
|
if (!canEditMessage(message) || !message.body) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -61,6 +61,8 @@ import { getConversationTitleForPanelType } from '../../util/getConversationTitl
|
||||||
import type { PanelRenderType } from '../../types/Panels';
|
import type { PanelRenderType } from '../../types/Panels';
|
||||||
import type { HasStories } from '../../types/Stories';
|
import type { HasStories } from '../../types/Stories';
|
||||||
import { getHasStoriesSelector } from './stories2';
|
import { getHasStoriesSelector } from './stories2';
|
||||||
|
import { canEditMessage } from '../../util/canEditMessage';
|
||||||
|
import { isOutgoing } from '../../messages/helpers';
|
||||||
|
|
||||||
export type ConversationWithStoriesType = ConversationType & {
|
export type ConversationWithStoriesType = ConversationType & {
|
||||||
hasStories?: HasStories;
|
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();
|
const collator = new Intl.Collator();
|
||||||
|
|
||||||
// Note: we will probably want to put i18n and regionCode back when we are formatting
|
// 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 =>
|
(i18n, panel): string | undefined =>
|
||||||
getConversationTitleForPanelType(i18n, panel?.type)
|
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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -68,7 +68,7 @@ import { isNotNil } from '../../util/isNotNil';
|
||||||
import { isMoreRecentThan } from '../../util/timestamp';
|
import { isMoreRecentThan } from '../../util/timestamp';
|
||||||
import * as iterables from '../../util/iterables';
|
import * as iterables from '../../util/iterables';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import { canEditMessages } from '../../util/canEditMessages';
|
import { canEditMessage } from '../../util/canEditMessage';
|
||||||
|
|
||||||
import { getAccountSelector } from './accounts';
|
import { getAccountSelector } from './accounts';
|
||||||
import {
|
import {
|
||||||
|
@ -130,7 +130,6 @@ import { getTitleNoDefault, getNumber } from '../../util/getTitle';
|
||||||
|
|
||||||
export { isIncoming, isOutgoing, isStory };
|
export { isIncoming, isOutgoing, isStory };
|
||||||
|
|
||||||
const MAX_EDIT_COUNT = 10;
|
|
||||||
const THREE_HOURS = 3 * HOUR;
|
const THREE_HOURS = 3 * HOUR;
|
||||||
const linkify = LinkifyIt();
|
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(
|
export function canDownload(
|
||||||
message: MessageWithUIFieldsType,
|
message: MessageWithUIFieldsType,
|
||||||
conversationSelector: GetConversationByIdType
|
conversationSelector: GetConversationByIdType
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { getEmojiSkinTone, getTextFormattingEnabled } from '../selectors/items';
|
||||||
import {
|
import {
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getGroupAdminsSelector,
|
getGroupAdminsSelector,
|
||||||
|
getLastEditableMessageId,
|
||||||
getSelectedMessageIds,
|
getSelectedMessageIds,
|
||||||
getTargetedConversationsPanelsCount,
|
getTargetedConversationsPanelsCount,
|
||||||
isMissingRequiredProfileSharing,
|
isMissingRequiredProfileSharing,
|
||||||
|
@ -126,6 +127,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
const isFormattingSpoilersFlagEnabled =
|
const isFormattingSpoilersFlagEnabled =
|
||||||
getIsFormattingSpoilersFlagEnabled(state);
|
getIsFormattingSpoilersFlagEnabled(state);
|
||||||
|
|
||||||
|
const lastEditableMessageId = getLastEditableMessageId(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Base
|
// Base
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
|
@ -137,6 +140,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
isFormattingEnabled,
|
isFormattingEnabled,
|
||||||
isFormattingFlagEnabled,
|
isFormattingFlagEnabled,
|
||||||
isFormattingSpoilersFlagEnabled,
|
isFormattingSpoilersFlagEnabled,
|
||||||
|
lastEditableMessageId,
|
||||||
messageCompositionId,
|
messageCompositionId,
|
||||||
platform,
|
platform,
|
||||||
sendCounter,
|
sendCounter,
|
||||||
|
|
24
ts/util/canEditMessage.ts
Normal file
24
ts/util/canEditMessage.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ import { ErrorWithToast } from '../types/ErrorWithToast';
|
||||||
import { SendStatus } from '../messages/MessageSendState';
|
import { SendStatus } from '../messages/MessageSendState';
|
||||||
import { ToastType } from '../types/Toast';
|
import { ToastType } from '../types/Toast';
|
||||||
import { UUID } from '../types/UUID';
|
import { UUID } from '../types/UUID';
|
||||||
import { canEditMessage } from '../state/selectors/message';
|
import { canEditMessage } from './canEditMessage';
|
||||||
import {
|
import {
|
||||||
conversationJobQueue,
|
conversationJobQueue,
|
||||||
conversationQueueJobEnum,
|
conversationQueueJobEnum,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue