diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 97f9e5a22db..28f0ad7d34e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2861,6 +2861,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 4c3d7ae8d0b..8f5c83585f3 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, isPollSend1to1Enabled: true, messageCompositionId: '456', sendEditedMessage: action('sendEditedMessage'), diff --git a/ts/components/CompositionArea.dom.tsx b/ts/components/CompositionArea.dom.tsx index 0effc83a72e..5052150c337 100644 --- a/ts/components/CompositionArea.dom.tsx +++ b/ts/components/CompositionArea.dom.tsx @@ -130,6 +130,7 @@ export type OwnProps = Readonly<{ isDisabled: boolean; isFetchingUUID: boolean | null; isFormattingEnabled: boolean; + isTypingAutoFocusEnabled: boolean; isGroupV1AndDisabled: boolean | null; isMissingMandatoryProfileSharing: boolean | null; isPollSend1to1Enabled: boolean; @@ -309,6 +310,7 @@ export const CompositionArea = memo(function CompositionArea({ draftText, getPreferredBadge, isFormattingEnabled, + isTypingAutoFocusEnabled, onEditorStateChange, onTextTooLong, ourConversationId, @@ -517,6 +519,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 6c3d0b0bb1c..1c602e4e008 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 f7166dd3413..4db66ff1620 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -157,6 +157,7 @@ export type PropsDataType = { hasSpellCheck: boolean | undefined; hasStoriesDisabled: boolean; hasTextFormatting: boolean; + hasTypingAutoFocus: boolean; hasTypingIndicators: boolean; hasKeepMutedChatsArchived: boolean; settingsLocation: SettingsLocation; @@ -328,6 +329,7 @@ type PropsFunctionType = { onSentMediaQualityChange: SelectChangeHandlerType; onSpellCheckChange: CheckboxChangeHandlerType; onTextFormattingChange: CheckboxChangeHandlerType; + onTypingAutoFocusChange: CheckboxChangeHandlerType; onThemeChange: SelectChangeHandlerType; onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; onUniversalExpireTimerChange: SelectChangeHandlerType; @@ -435,6 +437,7 @@ export function Preferences({ hasSpellCheck, hasStoriesDisabled, hasTextFormatting, + hasTypingAutoFocus, hasTypingIndicators, hasKeepMutedChatsArchived, i18n, @@ -492,6 +495,7 @@ export function Preferences({ onSentMediaQualityChange, onSpellCheckChange, onTextFormattingChange, + onTypingAutoFocusChange, onThemeChange, onToggleNavTabsCollapse, onUniversalExpireTimerChange, @@ -1152,6 +1156,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 e052891ddfc..767c2fe9c65 100644 --- a/ts/state/smart/CompositionArea.preload.tsx +++ b/ts/state/smart/CompositionArea.preload.tsx @@ -38,6 +38,7 @@ import { getEmojiSkinToneDefault, getItems, getTextFormattingEnabled, + getTypingAutoFocus, } from '../selectors/items.dom.js'; import { canForward, getPropsForQuote } from '../selectors/message.preload.js'; import { @@ -89,6 +90,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ const selectedMessageIds = useSelector(getSelectedMessageIds); const messageLookup = useSelector(getMessages); const isFormattingEnabled = useSelector(getTextFormattingEnabled); + const isTypingAutoFocusEnabled = useSelector(getTypingAutoFocus); const items = useSelector(getItems); const version = useSelector(getVersion); const lastEditableMessageId = useSelector(getLastEditableMessageId); @@ -250,6 +252,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ i18n={i18n} isDisabled={isDisabled} isFormattingEnabled={isFormattingEnabled} + isTypingAutoFocusEnabled={isTypingAutoFocusEnabled} isPollSend1to1Enabled={isFeaturedEnabledSelector({ betaKey: 'desktop.pollSend1to1.beta', prodKey: 'desktop.pollSend1to1.prod', diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index af991b790d4..35bafc1856b 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -709,6 +709,10 @@ export function SmartPreferences(): React.JSX.Element | null { 'textFormatting', true ); + const [hasTypingAutoFocus, onTypingAutoFocusChange] = createItemsAccess( + 'typingAutoFocus', + true + ); const [lastSyncTime, onLastSyncTimeChange] = createItemsAccess( 'synced_at', undefined @@ -868,6 +872,7 @@ export function SmartPreferences(): React.JSX.Element | null { hasSpellCheck={hasSpellCheck} hasStoriesDisabled={hasStoriesDisabled} hasTextFormatting={hasTextFormatting} + hasTypingAutoFocus={hasTypingAutoFocus} hasTypingIndicators={hasTypingIndicators} i18n={i18n} initialSpellCheckSetting={initialSpellCheckSetting} @@ -932,6 +937,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 8d35dda5dfc..16649b87a1c 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -141,6 +141,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 dba5521239b..f70d6437b6b 100644 --- a/ts/types/StorageUIKeys.std.ts +++ b/ts/types/StorageUIKeys.std.ts @@ -38,6 +38,7 @@ export const STORAGE_UI_KEYS: ReadonlyArray = [ 'showStickersIntroduction', 'emojiSkinToneDefault', 'textFormatting', + 'typingAutoFocus', 'version', 'zoomFactor', ];