mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 04:09:49 +00:00
Improve reliability of keyboard shortcuts for composer
This commit is contained in:
@@ -52,7 +52,6 @@ import { Quote } from './conversation/Quote.dom.js';
|
|||||||
import {
|
import {
|
||||||
useAttachFileShortcut,
|
useAttachFileShortcut,
|
||||||
useEditLastMessageSent,
|
useEditLastMessageSent,
|
||||||
useKeyboardShortcutsConditionally,
|
|
||||||
} from '../hooks/useKeyboardShortcuts.dom.js';
|
} from '../hooks/useKeyboardShortcuts.dom.js';
|
||||||
import { MediaEditor } from './MediaEditor.dom.js';
|
import { MediaEditor } from './MediaEditor.dom.js';
|
||||||
import { isImageTypeSupported } from '../util/GoogleChrome.std.js';
|
import { isImageTypeSupported } from '../util/GoogleChrome.std.js';
|
||||||
@@ -82,6 +81,7 @@ import { AxoButton } from '../axo/AxoButton.dom.js';
|
|||||||
import { tw } from '../axo/tw.dom.js';
|
import { tw } from '../axo/tw.dom.js';
|
||||||
import { isPollSendEnabled, type PollCreateType } from '../types/Polls.dom.js';
|
import { isPollSendEnabled, type PollCreateType } from '../types/Polls.dom.js';
|
||||||
import { PollCreateModal } from './PollCreateModal.dom.js';
|
import { PollCreateModal } from './PollCreateModal.dom.js';
|
||||||
|
import { useDocumentKeyDown } from '../hooks/useDocumentKeyDown.dom.js';
|
||||||
|
|
||||||
export type OwnProps = Readonly<{
|
export type OwnProps = Readonly<{
|
||||||
acceptedMessageRequest: boolean | null;
|
acceptedMessageRequest: boolean | null;
|
||||||
@@ -479,15 +479,15 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
setMessageToEdit,
|
setMessageToEdit,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [hasFocus, setHasFocus] = useState(false);
|
|
||||||
|
|
||||||
const attachFileShortcut = useAttachFileShortcut(launchFilePicker);
|
const attachFileShortcut = useAttachFileShortcut(launchFilePicker);
|
||||||
const editLastMessageSent = useEditLastMessageSent(maybeEditMessage);
|
const editLastMessageSent = useEditLastMessageSent(maybeEditMessage);
|
||||||
useKeyboardShortcutsConditionally(
|
useDocumentKeyDown(event => {
|
||||||
hasFocus,
|
const hasFocus = inputApiRef.current?.hasFocus() ?? false;
|
||||||
attachFileShortcut,
|
if (hasFocus) {
|
||||||
editLastMessageSent
|
attachFileShortcut(event);
|
||||||
);
|
editLastMessageSent(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Focus input on first mount
|
// Focus input on first mount
|
||||||
const previousFocusCounter = usePrevious<number | undefined>(
|
const previousFocusCounter = usePrevious<number | undefined>(
|
||||||
@@ -497,14 +497,12 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputApiRef.current) {
|
if (inputApiRef.current) {
|
||||||
inputApiRef.current.focus();
|
inputApiRef.current.focus();
|
||||||
setHasFocus(true);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
// Focus input whenever explicitly requested
|
// Focus input whenever explicitly requested
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusCounter !== previousFocusCounter && inputApiRef.current) {
|
if (focusCounter !== previousFocusCounter && inputApiRef.current) {
|
||||||
inputApiRef.current.focus();
|
inputApiRef.current.focus();
|
||||||
setHasFocus(true);
|
|
||||||
}
|
}
|
||||||
}, [inputApiRef, focusCounter, previousFocusCounter]);
|
}, [inputApiRef, focusCounter, previousFocusCounter]);
|
||||||
|
|
||||||
@@ -1162,8 +1160,6 @@ export const CompositionArea = memo(function CompositionArea({
|
|||||||
large={large}
|
large={large}
|
||||||
linkPreviewLoading={linkPreviewLoading}
|
linkPreviewLoading={linkPreviewLoading}
|
||||||
linkPreviewResult={linkPreviewResult}
|
linkPreviewResult={linkPreviewResult}
|
||||||
onBlur={() => setHasFocus(false)}
|
|
||||||
onFocus={() => setHasFocus(true)}
|
|
||||||
onCloseLinkPreview={onCloseLinkPreview}
|
onCloseLinkPreview={onCloseLinkPreview}
|
||||||
onDirtyChange={setDirty}
|
onDirtyChange={setDirty}
|
||||||
onEditorStateChange={onEditorStateChange}
|
onEditorStateChange={onEditorStateChange}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ Quill.register(
|
|||||||
|
|
||||||
export type InputApi = {
|
export type InputApi = {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
|
hasFocus: () => boolean;
|
||||||
insertEmoji: (emojiSelection: FunEmojiSelection) => void;
|
insertEmoji: (emojiSelection: FunEmojiSelection) => void;
|
||||||
setContents: (
|
setContents: (
|
||||||
text: string,
|
text: string,
|
||||||
@@ -273,7 +274,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const focus = () => {
|
const focus = React.useCallback(() => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
|
||||||
if (quill === undefined) {
|
if (quill === undefined) {
|
||||||
@@ -281,34 +282,37 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
quill.focus();
|
quill.focus();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const insertEmoji = (emojiSelection: FunEmojiSelection) => {
|
const insertEmoji = React.useCallback(
|
||||||
const quill = quillRef.current;
|
(emojiSelection: FunEmojiSelection) => {
|
||||||
|
const quill = quillRef.current;
|
||||||
|
|
||||||
if (quill === undefined) {
|
if (quill === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const range = quill.getSelection();
|
const range = quill.getSelection();
|
||||||
|
|
||||||
const insertionRange = range || lastSelectionRange;
|
const insertionRange = range || lastSelectionRange;
|
||||||
if (insertionRange == null) {
|
if (insertionRange == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emojiVariant = getEmojiVariantByKey(emojiSelection.variantKey);
|
const emojiVariant = getEmojiVariantByKey(emojiSelection.variantKey);
|
||||||
|
|
||||||
const delta = new Delta()
|
const delta = new Delta()
|
||||||
.retain(insertionRange.index)
|
.retain(insertionRange.index)
|
||||||
.delete(insertionRange.length)
|
.delete(insertionRange.length)
|
||||||
.insert({ emoji: { value: emojiVariant.value } });
|
.insert({ emoji: { value: emojiVariant.value } });
|
||||||
|
|
||||||
quill.updateContents(delta, 'user');
|
quill.updateContents(delta, 'user');
|
||||||
quill.setSelection(insertionRange.index + 1, 0, 'user');
|
quill.setSelection(insertionRange.index + 1, 0, 'user');
|
||||||
};
|
},
|
||||||
|
[lastSelectionRange]
|
||||||
|
);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = React.useCallback(() => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
|
||||||
if (quill === undefined) {
|
if (quill === undefined) {
|
||||||
@@ -319,29 +323,32 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||||||
quill.setText('');
|
quill.setText('');
|
||||||
|
|
||||||
quill.history.clear();
|
quill.history.clear();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const setContents = (
|
const setContents = React.useCallback(
|
||||||
text: string,
|
(
|
||||||
bodyRanges?: HydratedBodyRangesType,
|
text: string,
|
||||||
cursorToEnd?: boolean
|
bodyRanges?: HydratedBodyRangesType,
|
||||||
) => {
|
cursorToEnd?: boolean
|
||||||
const quill = quillRef.current;
|
) => {
|
||||||
|
const quill = quillRef.current;
|
||||||
|
|
||||||
if (quill === undefined) {
|
if (quill === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = generateDelta(text || '', bodyRanges || []);
|
const delta = generateDelta(text || '', bodyRanges || []);
|
||||||
|
|
||||||
canSendRef.current = true;
|
canSendRef.current = true;
|
||||||
quill.setContents(delta);
|
quill.setContents(delta);
|
||||||
if (cursorToEnd) {
|
if (cursorToEnd) {
|
||||||
quill.setSelection(quill.getLength(), 0);
|
quill.setSelection(quill.getLength(), 0);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = React.useCallback(() => {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
|
||||||
@@ -365,17 +372,22 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||||||
if (!didSend) {
|
if (!didSend) {
|
||||||
canSendRef.current = true;
|
canSendRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
}, [onSubmit]);
|
||||||
|
|
||||||
if (inputApi) {
|
const hasFocus = React.useCallback(() => {
|
||||||
inputApi.current = {
|
return quillRef.current?.hasFocus() ?? false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useImperativeHandle(inputApi, () => {
|
||||||
|
return {
|
||||||
focus,
|
focus,
|
||||||
|
hasFocus,
|
||||||
insertEmoji,
|
insertEmoji,
|
||||||
setContents,
|
setContents,
|
||||||
reset,
|
reset,
|
||||||
submit,
|
submit,
|
||||||
};
|
};
|
||||||
}
|
}, [focus, hasFocus, insertEmoji, reset, setContents, submit]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
propsRef.current = props;
|
propsRef.current = props;
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ import type {
|
|||||||
} from './Message.dom.js';
|
} from './Message.dom.js';
|
||||||
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations.preload.js';
|
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations.preload.js';
|
||||||
import { doesMessageBodyOverflow } from './MessageBodyReadMore.dom.js';
|
import { doesMessageBodyOverflow } from './MessageBodyReadMore.dom.js';
|
||||||
import {
|
import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts.dom.js';
|
||||||
useKeyboardShortcutsConditionally,
|
|
||||||
useToggleReactionPicker,
|
|
||||||
} from '../../hooks/useKeyboardShortcuts.dom.js';
|
|
||||||
import { PanelType } from '../../types/Panels.std.js';
|
import { PanelType } from '../../types/Panels.std.js';
|
||||||
import type {
|
import type {
|
||||||
DeleteMessagesPropsType,
|
DeleteMessagesPropsType,
|
||||||
@@ -40,6 +37,7 @@ import { isNotNil } from '../../util/isNotNil.std.js';
|
|||||||
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
|
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
|
||||||
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js';
|
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js';
|
||||||
import { PinMessageDialog } from './pinned-messages/PinMessageDialog.dom.js';
|
import { PinMessageDialog } from './pinned-messages/PinMessageDialog.dom.js';
|
||||||
|
import { useDocumentKeyDown } from '../../hooks/useDocumentKeyDown.dom.js';
|
||||||
|
|
||||||
const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu;
|
const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu;
|
||||||
|
|
||||||
@@ -283,10 +281,11 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
handleReact || noop
|
handleReact || noop
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardShortcutsConditionally(
|
useDocumentKeyDown(event => {
|
||||||
Boolean(isTargeted),
|
if (isTargeted) {
|
||||||
toggleReactionPickerKeyboard
|
toggleReactionPickerKeyboard(event);
|
||||||
);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const groupedReactions = useGroupedAndOrderedReactions(
|
const groupedReactions = useGroupedAndOrderedReactions(
|
||||||
props.reactions,
|
props.reactions,
|
||||||
|
|||||||
51
ts/hooks/useDocumentKeyDown.dom.ts
Normal file
51
ts/hooks/useDocumentKeyDown.dom.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
type KeyDownHandler = (e: KeyboardEvent) => void;
|
||||||
|
const handlers = new Set<KeyDownHandler>();
|
||||||
|
|
||||||
|
let isListenerAttached = false;
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKeyDownHandler(handler: KeyDownHandler) {
|
||||||
|
handlers.add(handler);
|
||||||
|
|
||||||
|
if (!isListenerAttached) {
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
isListenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKeyDownCallback(handler: KeyDownHandler) {
|
||||||
|
handlers.delete(handler);
|
||||||
|
|
||||||
|
if (isListenerAttached && handlers.size === 0) {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
isListenerAttached = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocumentKeyDown(
|
||||||
|
listener: (event: KeyboardEvent) => void
|
||||||
|
): void {
|
||||||
|
const listenerRef = useRef(listener);
|
||||||
|
useEffect(() => {
|
||||||
|
listenerRef.current = listener;
|
||||||
|
}, [listener]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handler(event: KeyboardEvent) {
|
||||||
|
listenerRef.current(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
addKeyDownHandler(handler);
|
||||||
|
return () => removeKeyDownCallback(handler);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -342,23 +342,3 @@ export function useKeyboardShortcuts(
|
|||||||
};
|
};
|
||||||
}, [eventHandlers]);
|
}, [eventHandlers]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcutsConditionally(
|
|
||||||
condition: boolean,
|
|
||||||
...eventHandlers: Array<KeyboardShortcutHandlerType>
|
|
||||||
): void {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!condition) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(ev: KeyboardEvent): void {
|
|
||||||
eventHandlers.some(eventHandler => eventHandler(ev));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeydown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleKeydown);
|
|
||||||
};
|
|
||||||
}, [condition, eventHandlers]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2328,5 +2328,12 @@
|
|||||||
"line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');",
|
"line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');",
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-09-17T21:02:59.414Z"
|
"updated": "2021-09-17T21:02:59.414Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "React-useRef",
|
||||||
|
"path": "ts/hooks/useDocumentKeyDown.dom.ts",
|
||||||
|
"line": " const listenerRef = useRef(listener);",
|
||||||
|
"reasonCategory": "usageTrusted",
|
||||||
|
"updated": "2025-12-09T15:37:49.757Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user