diff --git a/package.json b/package.json index 1871a8e0af..8e290f4e5c 100644 --- a/package.json +++ b/package.json @@ -199,6 +199,7 @@ "react": "19.1.0", "react-compiler-runtime": "^19.1.0-rc.1", "react-dom": "19.1.0", + "react-hotkeys-hook": "5.2.4", "react-image-crop": "^11.0.7", "react-is": "19", "react-keyed-flatten-children": "^5.0.0", diff --git a/src/App.native.tsx b/src/App.native.tsx index 6f6a2220c9..026f6079b1 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -11,8 +11,7 @@ import { import * as ScreenOrientation from 'expo-screen-orientation' import * as SplashScreen from 'expo-splash-screen' import * as SystemUI from 'expo-system-ui' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import * as Sentry from '@sentry/react-native' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' @@ -89,9 +88,9 @@ import {Splash} from '#/Splash' import {BottomSheetProvider} from '../modules/bottom-sheet' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' -SplashScreen.preventAutoHideAsync() +void SplashScreen.preventAutoHideAsync() if (IS_IOS) { - SystemUI.setBackgroundColorAsync('black') + void SystemUI.setBackgroundColorAsync('black') } if (IS_ANDROID) { // iOS is handled by the config plugin -sfn @@ -105,17 +104,17 @@ if (IS_ANDROID) { /** * Begin geolocation ASAP */ -Geo.resolve() -prefetchAgeAssuranceConfig() -prefetchLiveEvents() -prefetchAppConfig() +void Geo.resolve() +void prefetchAgeAssuranceConfig() +void prefetchLiveEvents() +void prefetchAppConfig() function InnerApp() { const [isReady, setIsReady] = useState(false) const {currentAccount} = useSession() const {resumeSession} = useSessionApi() const theme = useColorModeTheme() - const {_} = useLingui() + const {t: l} = useLingui() const hasCheckedReferrer = useStarterPackEntry() // init @@ -134,16 +133,16 @@ function InnerApp() { } } const account = readLastActiveAccount() - onLaunch(account) + void onLaunch(account) }, [resumeSession]) useEffect(() => { return listenSessionDropped(() => { - Toast.show(_(msg`Sorry! Your session expired. Please sign in again.`), { + Toast.show(l`Sorry! Your session expired. Please sign in again.`, { type: 'info', }) }) - }, [_]) + }, [l]) return ( @@ -220,8 +219,8 @@ function App() { const [isReady, setReady] = useState(false) useEffect(() => { - Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => - setReady(true), + void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then( + () => setReady(true), ) }, []) diff --git a/src/App.web.tsx b/src/App.web.tsx index fc2a5b1650..c391d69925 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -5,10 +5,10 @@ import './style.css' import {Fragment, useEffect, useState} from 'react' import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller' import {SafeAreaProvider} from 'react-native-safe-area-context' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import * as Sentry from '@sentry/react-native' +import {Provider as HotkeysProvider} from '#/lib/hotkeys' import {QueryProvider} from '#/lib/react-query' import {ThemeProvider} from '#/lib/ThemeContext' import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' @@ -82,17 +82,17 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom /** * Begin geolocation ASAP */ -Geo.resolve() -prefetchAgeAssuranceConfig() -prefetchLiveEvents() -prefetchAppConfig() +void Geo.resolve() +void prefetchAgeAssuranceConfig() +void prefetchLiveEvents() +void prefetchAppConfig() function InnerApp() { const [isReady, setIsReady] = useState(false) const {currentAccount} = useSession() const {resumeSession} = useSessionApi() const theme = useColorModeTheme() - const {_} = useLingui() + const {t: l} = useLingui() const hasCheckedReferrer = useStarterPackEntry() // init @@ -105,22 +105,22 @@ function InnerApp() { await features.init } } catch (e) { - logger.error(`session: resumeSession failed`, {message: e}) + logger.error('session: resumeSession failed', {message: e}) } finally { setIsReady(true) } } const account = readLastActiveAccount() - onLaunch(account) + void onLaunch(account) }, [resumeSession]) useEffect(() => { return listenSessionDropped(() => { - Toast.show(_(msg`Sorry! Your session expired. Please sign in again.`), { + Toast.show(l`Sorry! Your session expired. Please sign in again.`, { type: 'info', }) }) - }, [_]) + }, [l]) return ( @@ -156,8 +156,10 @@ function InnerApp() { - - + + + + @@ -195,8 +197,8 @@ function App() { const [isReady, setReady] = useState(false) useEffect(() => { - Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() => - setReady(true), + void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then( + () => setReady(true), ) }, []) diff --git a/src/components/forms/SearchInput.tsx b/src/components/forms/SearchInput.tsx index 47829101ec..8b54b44246 100644 --- a/src/components/forms/SearchInput.tsx +++ b/src/components/forms/SearchInput.tsx @@ -1,8 +1,9 @@ -import {forwardRef} from 'react' +import {useEffect, useRef} from 'react' import {type TextInput, View} from 'react-native' import {useLingui} from '@lingui/react/macro' import {HITSLOP_10} from '#/lib/constants' +import {listenFocusSearch} from '#/state/events' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import * as TextField from '#/components/forms/TextField' @@ -10,73 +11,89 @@ import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {IS_NATIVE} from '#/env' -type SearchInputProps = Omit & { +type Props = Omit & { label?: TextField.InputProps['label'] /** * Called when the user presses the (X) button */ onClearText?: () => void + hotkey?: boolean + ref?: React.RefObject } -export const SearchInput = forwardRef( - function SearchInput({value, label, onClearText, ...rest}, ref) { - const t = useTheme() - const {t: l} = useLingui() - const showClear = value && value.length > 0 +export function SearchInput({ + value, + label, + onClearText, + hotkey, + ref, + ...rest +}: Props) { + const t = useTheme() + const {t: l} = useLingui() + const showClear = value && value.length > 0 + const internalRef = useRef(null) + const inputRef = ref ?? internalRef - return ( - - - - - + useEffect(() => { + if (!hotkey) return + return listenFocusSearch(() => { + inputRef.current?.focus() + }) + }, [hotkey, inputRef]) - {showClear && ( - - - - )} - - ) - }, -) + return ( + + + + + + + {showClear && ( + + + + )} + + ) +} diff --git a/src/lib/hotkeys/index.tsx b/src/lib/hotkeys/index.tsx new file mode 100644 index 0000000000..6cddf3b13b --- /dev/null +++ b/src/lib/hotkeys/index.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {useLingui} from '@lingui/react/macro' +import { + HotkeysProvider, + useHotkeys, + useHotkeysContext, +} from 'react-hotkeys-hook' + +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {emitFocusSearch} from '#/state/events' +import {useSession} from '#/state/session' + +enum Hotkeys { + OPEN_COMPOSER = 'n', + FOCUS_SEARCH = 'slash', +} + +export function Provider({children}: React.PropsWithChildren) { + return ( + + {children} + + ) +} + +export {useHotkeysContext} + +function KeyboardShortcuts({children}: React.PropsWithChildren) { + useKeyboardShortcuts() + return children +} + +function useKeyboardShortcuts() { + const {openComposer} = useOpenComposer() + const {hasSession} = useSession() + const {t: l} = useLingui() + + const shouldIgnore = (requiresSession: boolean = false) => { + if (requiresSession && !hasSession) { + return true + } + + return false + } + + const handleKey = ( + callback: () => void, + options?: {requiresSession?: boolean}, + ) => { + if (shouldIgnore(options?.requiresSession)) { + return + } + callback() + } + + useHotkeys( + Hotkeys.OPEN_COMPOSER, + () => + handleKey( + () => { + openComposer({logContext: 'Other'}) + }, + { + requiresSession: true, + }, + ), + {scopes: ['global'], description: l`Compose new post`}, + [openComposer], + ) + + useHotkeys(Hotkeys.FOCUS_SEARCH, () => handleKey(emitFocusSearch), { + scopes: ['global'], + preventDefault: true, + description: l`Focus the search field`, + }) +} diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index f46812285a..ac0ad75483 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -380,6 +380,7 @@ export function SearchScreenShell({ inputPlaceholder ?? l`Search for posts, users, or feeds` } hitSlop={{...HITSLOP_20, top: 0}} + hotkey={true} /> {showAutocomplete && ( diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 93170f6275..f9711bfa3a 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -7,6 +7,7 @@ import { useState, } from 'react' +import {useHotkeysContext} from '#/lib/hotkeys' import {type DialogControlRefProps} from '#/components/Dialog' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' import {IS_WEB} from '#/env' @@ -62,6 +63,7 @@ export function useDialogFullyExpandedCountContext() { export function Provider({children}: React.PropsWithChildren<{}>) { const [fullyExpandedCount, setFullyExpandedCount] = useState(0) + const {disableScope, enableScope} = useHotkeysContext() const activeDialogs = useRef< Map> @@ -77,18 +79,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return openDialogs.current.size > 0 } else { - BottomSheetNativeComponent.dismissAll() + void BottomSheetNativeComponent.dismissAll() return false } }, []) - const setDialogIsOpen = useCallback((id: string, isOpen: boolean) => { - if (isOpen) { - openDialogs.current.add(id) - } else { - openDialogs.current.delete(id) - } - }, []) + const setDialogIsOpen = useCallback( + (id: string, isOpen: boolean) => { + if (isOpen) { + openDialogs.current.add(id) + } else { + openDialogs.current.delete(id) + } + if (openDialogs.current.size > 0) { + disableScope('global') + } else { + enableScope('global') + } + }, + [disableScope, enableScope], + ) const context = useMemo( () => ({ diff --git a/src/state/events.ts b/src/state/events.ts index 2ab72465de..87a1fab705 100644 --- a/src/state/events.ts +++ b/src/state/events.ts @@ -45,3 +45,11 @@ export function listenPostCreated(fn: () => void): UnlistenFn { emitter.on('post-created', fn) return () => emitter.off('post-created', fn) } + +export function emitFocusSearch() { + emitter.emit('focus-search') +} +export function listenFocusSearch(fn: () => void): UnlistenFn { + emitter.on('focus-search', fn) + return () => emitter.off('focus-search', fn) +} diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx index 52c74278cb..1e22cc98a4 100644 --- a/src/state/lightbox.tsx +++ b/src/state/lightbox.tsx @@ -1,7 +1,8 @@ -import {createContext, useContext, useMemo, useState} from 'react' +import {createContext, useContext, useEffect, useMemo, useState} from 'react' import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {useHotkeysContext} from '#/lib/hotkeys' import {type ImageSource} from '#/view/com/lightbox/ImageViewing/@types' export type Lightbox = { @@ -28,6 +29,15 @@ LightboxControlContext.displayName = 'LightboxControlContext' export function Provider({children}: React.PropsWithChildren<{}>) { const [activeLightbox, setActiveLightbox] = useState(null) + const {disableScope, enableScope} = useHotkeysContext() + + useEffect(() => { + if (activeLightbox) { + disableScope('global') + } else { + enableScope('global') + } + }, [activeLightbox, disableScope, enableScope]) const openLightbox = useNonReactiveCallback( (lightbox: Omit) => { diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 484890ba15..3c545362b8 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,6 +1,7 @@ -import {createContext, useContext, useMemo, useState} from 'react' +import {createContext, useContext, useEffect, useMemo, useState} from 'react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {useHotkeysContext} from '#/lib/hotkeys' export interface UserAddRemoveListsModal { name: 'user-add-remove-lists' @@ -47,6 +48,15 @@ ModalControlContext.displayName = 'ModalControlContext' export function Provider({children}: React.PropsWithChildren<{}>) { const [activeModals, setActiveModals] = useState([]) + const {disableScope, enableScope} = useHotkeysContext() + + useEffect(() => { + if (activeModals.length > 0) { + disableScope('global') + } else { + enableScope('global') + } + }, [activeModals.length, disableScope, enableScope]) const openModal = useNonReactiveCallback((modal: Modal) => { setActiveModals(modals => [...modals, modal]) diff --git a/src/state/shell/composer/useComposerKeyboardShortcut.tsx b/src/state/shell/composer/useComposerKeyboardShortcut.tsx deleted file mode 100644 index a1e76fdfd9..0000000000 --- a/src/state/shell/composer/useComposerKeyboardShortcut.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import {useEffect} from 'react' - -import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {useDialogStateContext} from '#/state/dialogs' -import {useLightbox} from '#/state/lightbox' -import {useModals} from '#/state/modals' -import {useSession} from '#/state/session' -import {useIsDrawerOpen} from '#/state/shell/drawer-open' - -/** - * Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2} - */ -function shouldIgnore(event: KeyboardEvent) { - const target: any = event.target || event.srcElement - if (!target) return false - const {tagName} = target - if (!tagName) return false - const isInput = - tagName === 'INPUT' && - ![ - 'checkbox', - 'radio', - 'range', - 'button', - 'file', - 'reset', - 'submit', - 'color', - ].includes(target.type) - // ignore: isContentEditable === 'true', and