From d7113594ebdca71c578ccdd230b4a83f33310f03 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:02:41 +0100 Subject: [PATCH] Auto-focus message composer on keypress Add auto-focus behavior that moves focus to the message composition input when a printable key is pressed, matching the UX of other major desktop messengers. Includes a user-facing toggle in Settings > Chats (default: on) and comprehensive guards against focus stealing from other inputs, keyboard shortcut interference, and non-printable keys. Supersedes #4998. --- _locales/en/messages.json | 8 +++ ts/components/CompositionArea.dom.stories.tsx | 1 + ts/components/CompositionArea.dom.tsx | 49 +++++++++++++++++++ ts/components/Preferences.dom.stories.tsx | 2 + ts/components/Preferences.dom.tsx | 14 ++++++ ts/state/selectors/items.dom.ts | 5 ++ ts/state/smart/CompositionArea.preload.tsx | 3 ++ ts/state/smart/Preferences.preload.tsx | 6 +++ ts/types/Storage.d.ts | 1 + ts/types/StorageUIKeys.std.ts | 1 + 10 files changed, 90 insertions(+) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5b812dfe051..4e4a2edda8c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2865,6 +2865,14 @@ "messageformat": "Show text formatting popover when text is selected", "description": "Description of the text-formatting popover menu setting" }, + "icu:Preferences__typing-auto-focus--title": { + "messageformat": "Auto-focus message input when typing", + "description": "Title for the setting that automatically focuses the message composition area when a key is pressed" + }, + "icu:Preferences__typing-auto-focus--description": { + "messageformat": "Automatically focus the message input field when you start typing, even if it isn't currently focused.", + "description": "Description for the auto-focus typing setting" + }, "icu:spellCheckWillBeEnabled": { "messageformat": "Spell check will be enabled the next time Signal starts.", "description": "Shown when the user enables spellcheck to indicate that they must restart Signal." diff --git a/ts/components/CompositionArea.dom.stories.tsx b/ts/components/CompositionArea.dom.stories.tsx index 374e8e5185d..8d619543d8d 100644 --- a/ts/components/CompositionArea.dom.stories.tsx +++ b/ts/components/CompositionArea.dom.stories.tsx @@ -79,6 +79,7 @@ export default { i18n, isDisabled: false, isFormattingEnabled: true, + isTypingAutoFocusEnabled: true, messageCompositionId: '456', sendEditedMessage: action('sendEditedMessage'), sendMultiMediaMessage: action('sendMultiMediaMessage'), diff --git a/ts/components/CompositionArea.dom.tsx b/ts/components/CompositionArea.dom.tsx index 988a8768c33..a361ffeb64d 100644 --- a/ts/components/CompositionArea.dom.tsx +++ b/ts/components/CompositionArea.dom.tsx @@ -123,6 +123,7 @@ export type OwnProps = Readonly<{ isDisabled: boolean; isFetchingUUID: boolean | null; isFormattingEnabled: boolean; + isTypingAutoFocusEnabled: boolean; isGroupV1AndDisabled: boolean | null; isMissingMandatoryProfileSharing: boolean | null; isSignalConversation: boolean | null; @@ -290,6 +291,7 @@ export const CompositionArea = memo(function CompositionArea({ draftText, getPreferredBadge, isFormattingEnabled, + isTypingAutoFocusEnabled, onEditorStateChange, onTextTooLong, ourConversationId, @@ -496,6 +498,53 @@ export const CompositionArea = memo(function CompositionArea({ } }); + // Auto-focus composer when user presses a printable key + useDocumentKeyDown( + useCallback( + (event: KeyboardEvent) => { + if (!isTypingAutoFocusEnabled) { + return; + } + + if (inputApiRef.current?.hasFocus()) { + return; + } + + // Printable keys have a single-char `key` value + if (event.key.length !== 1) { + return; + } + + // Don't hijack keyboard shortcuts + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + + // Don't steal focus from other input elements + const active = document.activeElement; + if (active) { + const tag = active.tagName.toLowerCase(); + if ( + tag === 'input' || + tag === 'textarea' || + tag === 'select' + ) { + return; + } + if ( + active.getAttribute('contenteditable') === 'true' || + active.getAttribute('role') === 'textbox' + ) { + return; + } + } + + inputApiRef.current?.focus(); + }, + [isTypingAutoFocusEnabled] + ) + ); + // Focus input on first mount const previousFocusCounter = usePrevious( focusCounter, diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index 70bcbf4bbea..8390359e4ae 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -452,6 +452,7 @@ export default { hasSpellCheck: true, hasStoriesDisabled: false, hasTextFormatting: true, + hasTypingAutoFocus: true, hasTypingIndicators: true, hasKeepMutedChatsArchived: false, initialSpellCheckSetting: true, @@ -588,6 +589,7 @@ export default { onSpellCheckChange: action('onSpellCheckChange'), onStartUpdate: action('onStartUpdate'), onTextFormattingChange: action('onTextFormattingChange'), + onTypingAutoFocusChange: action('onTypingAutoFocusChange'), onThemeChange: action('onThemeChange'), onToggleNavTabsCollapse: action('onToggleNavTabsCollapse'), onUniversalExpireTimerChange: action('onUniversalExpireTimerChange'), diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index 367832128c1..f22466c0051 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -156,6 +156,7 @@ export type PropsDataType = { hasSpellCheck: boolean | undefined; hasStoriesDisabled: boolean; hasTextFormatting: boolean; + hasTypingAutoFocus: boolean; hasTypingIndicators: boolean; hasKeepMutedChatsArchived: boolean; settingsLocation: SettingsLocation; @@ -326,6 +327,7 @@ type PropsFunctionType = { onSentMediaQualityChange: SelectChangeHandlerType; onSpellCheckChange: CheckboxChangeHandlerType; onTextFormattingChange: CheckboxChangeHandlerType; + onTypingAutoFocusChange: CheckboxChangeHandlerType; onThemeChange: SelectChangeHandlerType; onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; onUniversalExpireTimerChange: SelectChangeHandlerType; @@ -433,6 +435,7 @@ export function Preferences({ hasSpellCheck, hasStoriesDisabled, hasTextFormatting, + hasTypingAutoFocus, hasTypingIndicators, hasKeepMutedChatsArchived, i18n, @@ -490,6 +493,7 @@ export function Preferences({ onSentMediaQualityChange, onSpellCheckChange, onTextFormattingChange, + onTypingAutoFocusChange, onThemeChange, onToggleNavTabsCollapse, onUniversalExpireTimerChange, @@ -1149,6 +1153,16 @@ export function Preferences({ name="textFormatting" onChange={onTextFormattingChange} /> + Boolean(state.textFormatting ?? true) ); +export const getTypingAutoFocus = createSelector( + getItems, + (state: ItemsStateType): boolean => Boolean(state.typingAutoFocus ?? true) +); + export const getNavTabsCollapsed = createSelector( getItems, (state: ItemsStateType): boolean => Boolean(state.navTabsCollapsed ?? false) diff --git a/ts/state/smart/CompositionArea.preload.tsx b/ts/state/smart/CompositionArea.preload.tsx index 88cfa68fe36..0831d383f0b 100644 --- a/ts/state/smart/CompositionArea.preload.tsx +++ b/ts/state/smart/CompositionArea.preload.tsx @@ -37,6 +37,7 @@ import { getDefaultConversationColor, getEmojiSkinToneDefault, getTextFormattingEnabled, + getTypingAutoFocus, } from '../selectors/items.dom.js'; import { canForward, getPropsForQuote } from '../selectors/message.preload.js'; import { @@ -85,6 +86,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ const selectedMessageIds = useSelector(getSelectedMessageIds); const messageLookup = useSelector(getMessages); const isFormattingEnabled = useSelector(getTextFormattingEnabled); + const isTypingAutoFocusEnabled = useSelector(getTypingAutoFocus); const lastEditableMessageId = useSelector(getLastEditableMessageId); const platform = useSelector(getPlatform); const shouldHidePopovers = useSelector(getHasPanelOpen); @@ -242,6 +244,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ i18n={i18n} isDisabled={isDisabled} isFormattingEnabled={isFormattingEnabled} + isTypingAutoFocusEnabled={isTypingAutoFocusEnabled} isActive={isActive} lastEditableMessageId={lastEditableMessageId ?? null} messageCompositionId={messageCompositionId} diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index e9b92f93e86..748e6fcb475 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -707,6 +707,10 @@ export function SmartPreferences(): React.JSX.Element | null { 'textFormatting', true ); + const [hasTypingAutoFocus, onTypingAutoFocusChange] = createItemsAccess( + 'typingAutoFocus', + true + ); const [lastSyncTime, onLastSyncTimeChange] = createItemsAccess( 'synced_at', undefined @@ -866,6 +870,7 @@ export function SmartPreferences(): React.JSX.Element | null { hasSpellCheck={hasSpellCheck} hasStoriesDisabled={hasStoriesDisabled} hasTextFormatting={hasTextFormatting} + hasTypingAutoFocus={hasTypingAutoFocus} hasTypingIndicators={hasTypingIndicators} i18n={i18n} initialSpellCheckSetting={initialSpellCheckSetting} @@ -930,6 +935,7 @@ export function SmartPreferences(): React.JSX.Element | null { onSentMediaQualityChange={onSentMediaQualityChange} onSpellCheckChange={onSpellCheckChange} onTextFormattingChange={onTextFormattingChange} + onTypingAutoFocusChange={onTypingAutoFocusChange} onThemeChange={onThemeChange} onToggleNavTabsCollapse={toggleNavTabsCollapse} onUniversalExpireTimerChange={onUniversalExpireTimerChange} diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 756df119e09..e130a9f11ef 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -142,6 +142,7 @@ export type StorageAccessType = { pinnedConversationIds: ReadonlyArray; preferContactAvatars: boolean; textFormatting: boolean; + typingAutoFocus: boolean; typingIndicators: boolean; sealedSenderIndicators: boolean; storageFetchComplete: boolean; diff --git a/ts/types/StorageUIKeys.std.ts b/ts/types/StorageUIKeys.std.ts index d13238b90a3..77f46a90c2b 100644 --- a/ts/types/StorageUIKeys.std.ts +++ b/ts/types/StorageUIKeys.std.ts @@ -39,6 +39,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray = [ 'showStickersIntroduction', 'emojiSkinToneDefault', 'textFormatting', + 'typingAutoFocus', 'version', 'zoomFactor', ];