Improve reliability of keyboard shortcuts for composer

This commit is contained in:
trevor-signal
2025-12-09 10:53:43 -05:00
committed by GitHub
parent bdc056a7c9
commit b638f4d5f2
6 changed files with 128 additions and 83 deletions

View File

@@ -52,7 +52,6 @@ import { Quote } from './conversation/Quote.dom.js';
import {
useAttachFileShortcut,
useEditLastMessageSent,
useKeyboardShortcutsConditionally,
} from '../hooks/useKeyboardShortcuts.dom.js';
import { MediaEditor } from './MediaEditor.dom.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 { isPollSendEnabled, type PollCreateType } from '../types/Polls.dom.js';
import { PollCreateModal } from './PollCreateModal.dom.js';
import { useDocumentKeyDown } from '../hooks/useDocumentKeyDown.dom.js';
export type OwnProps = Readonly<{
acceptedMessageRequest: boolean | null;
@@ -479,15 +479,15 @@ export const CompositionArea = memo(function CompositionArea({
setMessageToEdit,
]);
const [hasFocus, setHasFocus] = useState(false);
const attachFileShortcut = useAttachFileShortcut(launchFilePicker);
const editLastMessageSent = useEditLastMessageSent(maybeEditMessage);
useKeyboardShortcutsConditionally(
hasFocus,
attachFileShortcut,
editLastMessageSent
);
useDocumentKeyDown(event => {
const hasFocus = inputApiRef.current?.hasFocus() ?? false;
if (hasFocus) {
attachFileShortcut(event);
editLastMessageSent(event);
}
});
// Focus input on first mount
const previousFocusCounter = usePrevious<number | undefined>(
@@ -497,14 +497,12 @@ export const CompositionArea = memo(function CompositionArea({
useEffect(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
setHasFocus(true);
}
}, []);
// Focus input whenever explicitly requested
useEffect(() => {
if (focusCounter !== previousFocusCounter && inputApiRef.current) {
inputApiRef.current.focus();
setHasFocus(true);
}
}, [inputApiRef, focusCounter, previousFocusCounter]);
@@ -1162,8 +1160,6 @@ export const CompositionArea = memo(function CompositionArea({
large={large}
linkPreviewLoading={linkPreviewLoading}
linkPreviewResult={linkPreviewResult}
onBlur={() => setHasFocus(false)}
onFocus={() => setHasFocus(true)}
onCloseLinkPreview={onCloseLinkPreview}
onDirtyChange={setDirty}
onEditorStateChange={onEditorStateChange}

View File

@@ -104,6 +104,7 @@ Quill.register(
export type InputApi = {
focus: () => void;
hasFocus: () => boolean;
insertEmoji: (emojiSelection: FunEmojiSelection) => void;
setContents: (
text: string,
@@ -273,7 +274,7 @@ export function CompositionInput(props: Props): React.ReactElement {
};
};
const focus = () => {
const focus = React.useCallback(() => {
const quill = quillRef.current;
if (quill === undefined) {
@@ -281,34 +282,37 @@ export function CompositionInput(props: Props): React.ReactElement {
}
quill.focus();
};
}, []);
const insertEmoji = (emojiSelection: FunEmojiSelection) => {
const quill = quillRef.current;
const insertEmoji = React.useCallback(
(emojiSelection: FunEmojiSelection) => {
const quill = quillRef.current;
if (quill === undefined) {
return;
}
if (quill === undefined) {
return;
}
const range = quill.getSelection();
const range = quill.getSelection();
const insertionRange = range || lastSelectionRange;
if (insertionRange == null) {
return;
}
const insertionRange = range || lastSelectionRange;
if (insertionRange == null) {
return;
}
const emojiVariant = getEmojiVariantByKey(emojiSelection.variantKey);
const emojiVariant = getEmojiVariantByKey(emojiSelection.variantKey);
const delta = new Delta()
.retain(insertionRange.index)
.delete(insertionRange.length)
.insert({ emoji: { value: emojiVariant.value } });
const delta = new Delta()
.retain(insertionRange.index)
.delete(insertionRange.length)
.insert({ emoji: { value: emojiVariant.value } });
quill.updateContents(delta, 'user');
quill.setSelection(insertionRange.index + 1, 0, 'user');
};
quill.updateContents(delta, 'user');
quill.setSelection(insertionRange.index + 1, 0, 'user');
},
[lastSelectionRange]
);
const reset = () => {
const reset = React.useCallback(() => {
const quill = quillRef.current;
if (quill === undefined) {
@@ -319,29 +323,32 @@ export function CompositionInput(props: Props): React.ReactElement {
quill.setText('');
quill.history.clear();
};
}, []);
const setContents = (
text: string,
bodyRanges?: HydratedBodyRangesType,
cursorToEnd?: boolean
) => {
const quill = quillRef.current;
const setContents = React.useCallback(
(
text: string,
bodyRanges?: HydratedBodyRangesType,
cursorToEnd?: boolean
) => {
const quill = quillRef.current;
if (quill === undefined) {
return;
}
if (quill === undefined) {
return;
}
const delta = generateDelta(text || '', bodyRanges || []);
const delta = generateDelta(text || '', bodyRanges || []);
canSendRef.current = true;
quill.setContents(delta);
if (cursorToEnd) {
quill.setSelection(quill.getLength(), 0);
}
};
canSendRef.current = true;
quill.setContents(delta);
if (cursorToEnd) {
quill.setSelection(quill.getLength(), 0);
}
},
[]
);
const submit = () => {
const submit = React.useCallback(() => {
const timestamp = Date.now();
const quill = quillRef.current;
@@ -365,17 +372,22 @@ export function CompositionInput(props: Props): React.ReactElement {
if (!didSend) {
canSendRef.current = true;
}
};
}, [onSubmit]);
if (inputApi) {
inputApi.current = {
const hasFocus = React.useCallback(() => {
return quillRef.current?.hasFocus() ?? false;
}, []);
React.useImperativeHandle(inputApi, () => {
return {
focus,
hasFocus,
insertEmoji,
setContents,
reset,
submit,
};
}
}, [focus, hasFocus, insertEmoji, reset, setContents, submit]);
React.useEffect(() => {
propsRef.current = props;

View File

@@ -23,10 +23,7 @@ import type {
} from './Message.dom.js';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations.preload.js';
import { doesMessageBodyOverflow } from './MessageBodyReadMore.dom.js';
import {
useKeyboardShortcutsConditionally,
useToggleReactionPicker,
} from '../../hooks/useKeyboardShortcuts.dom.js';
import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts.dom.js';
import { PanelType } from '../../types/Panels.std.js';
import type {
DeleteMessagesPropsType,
@@ -40,6 +37,7 @@ import { isNotNil } from '../../util/isNotNil.std.js';
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js';
import { PinMessageDialog } from './pinned-messages/PinMessageDialog.dom.js';
import { useDocumentKeyDown } from '../../hooks/useDocumentKeyDown.dom.js';
const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu;
@@ -283,10 +281,11 @@ export function TimelineMessage(props: Props): JSX.Element {
handleReact || noop
);
useKeyboardShortcutsConditionally(
Boolean(isTargeted),
toggleReactionPickerKeyboard
);
useDocumentKeyDown(event => {
if (isTargeted) {
toggleReactionPickerKeyboard(event);
}
});
const groupedReactions = useGroupedAndOrderedReactions(
props.reactions,

View 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);
}, []);
}

View File

@@ -342,23 +342,3 @@ export function useKeyboardShortcuts(
};
}, [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]);
}

View File

@@ -2328,5 +2328,12 @@
"line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');",
"reasonCategory": "usageTrusted",
"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"
}
]