mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-19 17:58:48 +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 {
|
||||
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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
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');",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user