Create global keyboard shortcut handler (#10145)
This commit is contained in:
@@ -199,6 +199,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-compiler-runtime": "^19.1.0-rc.1",
|
"react-compiler-runtime": "^19.1.0-rc.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hotkeys-hook": "5.2.4",
|
||||||
"react-image-crop": "^11.0.7",
|
"react-image-crop": "^11.0.7",
|
||||||
"react-is": "19",
|
"react-is": "19",
|
||||||
"react-keyed-flatten-children": "^5.0.0",
|
"react-keyed-flatten-children": "^5.0.0",
|
||||||
|
|||||||
+13
-14
@@ -11,8 +11,7 @@ import {
|
|||||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||||
import * as SplashScreen from 'expo-splash-screen'
|
import * as SplashScreen from 'expo-splash-screen'
|
||||||
import * as SystemUI from 'expo-system-ui'
|
import * as SystemUI from 'expo-system-ui'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import * as Sentry from '@sentry/react-native'
|
import * as Sentry from '@sentry/react-native'
|
||||||
|
|
||||||
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
|
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
|
||||||
@@ -89,9 +88,9 @@ import {Splash} from '#/Splash'
|
|||||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
void SplashScreen.preventAutoHideAsync()
|
||||||
if (IS_IOS) {
|
if (IS_IOS) {
|
||||||
SystemUI.setBackgroundColorAsync('black')
|
void SystemUI.setBackgroundColorAsync('black')
|
||||||
}
|
}
|
||||||
if (IS_ANDROID) {
|
if (IS_ANDROID) {
|
||||||
// iOS is handled by the config plugin -sfn
|
// iOS is handled by the config plugin -sfn
|
||||||
@@ -105,17 +104,17 @@ if (IS_ANDROID) {
|
|||||||
/**
|
/**
|
||||||
* Begin geolocation ASAP
|
* Begin geolocation ASAP
|
||||||
*/
|
*/
|
||||||
Geo.resolve()
|
void Geo.resolve()
|
||||||
prefetchAgeAssuranceConfig()
|
void prefetchAgeAssuranceConfig()
|
||||||
prefetchLiveEvents()
|
void prefetchLiveEvents()
|
||||||
prefetchAppConfig()
|
void prefetchAppConfig()
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const [isReady, setIsReady] = useState(false)
|
const [isReady, setIsReady] = useState(false)
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {resumeSession} = useSessionApi()
|
const {resumeSession} = useSessionApi()
|
||||||
const theme = useColorModeTheme()
|
const theme = useColorModeTheme()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const hasCheckedReferrer = useStarterPackEntry()
|
const hasCheckedReferrer = useStarterPackEntry()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
@@ -134,16 +133,16 @@ function InnerApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const account = readLastActiveAccount()
|
const account = readLastActiveAccount()
|
||||||
onLaunch(account)
|
void onLaunch(account)
|
||||||
}, [resumeSession])
|
}, [resumeSession])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return listenSessionDropped(() => {
|
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',
|
type: 'info',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}, [_])
|
}, [l])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
@@ -220,8 +219,8 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
|
||||||
setReady(true),
|
() => setReady(true),
|
||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
+15
-13
@@ -5,10 +5,10 @@ import './style.css'
|
|||||||
import {Fragment, useEffect, useState} from 'react'
|
import {Fragment, useEffect, useState} from 'react'
|
||||||
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
|
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
|
||||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import * as Sentry from '@sentry/react-native'
|
import * as Sentry from '@sentry/react-native'
|
||||||
|
|
||||||
|
import {Provider as HotkeysProvider} from '#/lib/hotkeys'
|
||||||
import {QueryProvider} from '#/lib/react-query'
|
import {QueryProvider} from '#/lib/react-query'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
|
||||||
@@ -82,17 +82,17 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
|||||||
/**
|
/**
|
||||||
* Begin geolocation ASAP
|
* Begin geolocation ASAP
|
||||||
*/
|
*/
|
||||||
Geo.resolve()
|
void Geo.resolve()
|
||||||
prefetchAgeAssuranceConfig()
|
void prefetchAgeAssuranceConfig()
|
||||||
prefetchLiveEvents()
|
void prefetchLiveEvents()
|
||||||
prefetchAppConfig()
|
void prefetchAppConfig()
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const [isReady, setIsReady] = useState(false)
|
const [isReady, setIsReady] = useState(false)
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {resumeSession} = useSessionApi()
|
const {resumeSession} = useSessionApi()
|
||||||
const theme = useColorModeTheme()
|
const theme = useColorModeTheme()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const hasCheckedReferrer = useStarterPackEntry()
|
const hasCheckedReferrer = useStarterPackEntry()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
@@ -105,22 +105,22 @@ function InnerApp() {
|
|||||||
await features.init
|
await features.init
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`session: resumeSession failed`, {message: e})
|
logger.error('session: resumeSession failed', {message: e})
|
||||||
} finally {
|
} finally {
|
||||||
setIsReady(true)
|
setIsReady(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const account = readLastActiveAccount()
|
const account = readLastActiveAccount()
|
||||||
onLaunch(account)
|
void onLaunch(account)
|
||||||
}, [resumeSession])
|
}, [resumeSession])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return listenSessionDropped(() => {
|
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',
|
type: 'info',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}, [_])
|
}, [l])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
@@ -156,8 +156,10 @@ function InnerApp() {
|
|||||||
<HideBottomBarBorderProvider>
|
<HideBottomBarBorderProvider>
|
||||||
<IntentDialogProvider>
|
<IntentDialogProvider>
|
||||||
<TranslateOnDeviceProvider>
|
<TranslateOnDeviceProvider>
|
||||||
|
<HotkeysProvider>
|
||||||
<Shell />
|
<Shell />
|
||||||
<ToastOutlet />
|
<ToastOutlet />
|
||||||
|
</HotkeysProvider>
|
||||||
</TranslateOnDeviceProvider>
|
</TranslateOnDeviceProvider>
|
||||||
</IntentDialogProvider>
|
</IntentDialogProvider>
|
||||||
</HideBottomBarBorderProvider>
|
</HideBottomBarBorderProvider>
|
||||||
@@ -195,8 +197,8 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
void Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(
|
||||||
setReady(true),
|
() => setReady(true),
|
||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {forwardRef} from 'react'
|
import {useEffect, useRef} from 'react'
|
||||||
import {type TextInput, View} from 'react-native'
|
import {type TextInput, View} from 'react-native'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
|
import {listenFocusSearch} from '#/state/events'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
@@ -10,26 +11,43 @@ import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/
|
|||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
|
|
||||||
type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
|
type Props = Omit<TextField.InputProps, 'label'> & {
|
||||||
label?: TextField.InputProps['label']
|
label?: TextField.InputProps['label']
|
||||||
/**
|
/**
|
||||||
* Called when the user presses the (X) button
|
* Called when the user presses the (X) button
|
||||||
*/
|
*/
|
||||||
onClearText?: () => void
|
onClearText?: () => void
|
||||||
|
hotkey?: boolean
|
||||||
|
ref?: React.RefObject<TextInput | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchInput = forwardRef<TextInput, SearchInputProps>(
|
export function SearchInput({
|
||||||
function SearchInput({value, label, onClearText, ...rest}, ref) {
|
value,
|
||||||
|
label,
|
||||||
|
onClearText,
|
||||||
|
hotkey,
|
||||||
|
ref,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const showClear = value && value.length > 0
|
const showClear = value && value.length > 0
|
||||||
|
const internalRef = useRef<TextInput>(null)
|
||||||
|
const inputRef = ref ?? internalRef
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hotkey) return
|
||||||
|
return listenFocusSearch(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
})
|
||||||
|
}, [hotkey, inputRef])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.w_full, a.relative]}>
|
<View style={[a.w_full, a.relative]}>
|
||||||
<TextField.Root>
|
<TextField.Root>
|
||||||
<TextField.Icon icon={MagnifyingGlassIcon} />
|
<TextField.Icon icon={MagnifyingGlassIcon} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
inputRef={ref}
|
inputRef={inputRef}
|
||||||
label={label || l`Search`}
|
label={label || l`Search`}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={l`Search`}
|
placeholder={l`Search`}
|
||||||
@@ -78,5 +96,4 @@ export const SearchInput = forwardRef<TextInput, SearchInputProps>(
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
|||||||
@@ -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<unknown>) {
|
||||||
|
return (
|
||||||
|
<HotkeysProvider initiallyActiveScopes={['global']}>
|
||||||
|
<KeyboardShortcuts>{children}</KeyboardShortcuts>
|
||||||
|
</HotkeysProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {useHotkeysContext}
|
||||||
|
|
||||||
|
function KeyboardShortcuts({children}: React.PropsWithChildren<unknown>) {
|
||||||
|
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`,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -380,6 +380,7 @@ export function SearchScreenShell({
|
|||||||
inputPlaceholder ?? l`Search for posts, users, or feeds`
|
inputPlaceholder ?? l`Search for posts, users, or feeds`
|
||||||
}
|
}
|
||||||
hitSlop={{...HITSLOP_20, top: 0}}
|
hitSlop={{...HITSLOP_20, top: 0}}
|
||||||
|
hotkey={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{showAutocomplete && (
|
{showAutocomplete && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
|
import {useHotkeysContext} from '#/lib/hotkeys'
|
||||||
import {type DialogControlRefProps} from '#/components/Dialog'
|
import {type DialogControlRefProps} from '#/components/Dialog'
|
||||||
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
|
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
@@ -62,6 +63,7 @@ export function useDialogFullyExpandedCountContext() {
|
|||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [fullyExpandedCount, setFullyExpandedCount] = useState(0)
|
const [fullyExpandedCount, setFullyExpandedCount] = useState(0)
|
||||||
|
const {disableScope, enableScope} = useHotkeysContext()
|
||||||
|
|
||||||
const activeDialogs = useRef<
|
const activeDialogs = useRef<
|
||||||
Map<string, React.MutableRefObject<DialogControlRefProps>>
|
Map<string, React.MutableRefObject<DialogControlRefProps>>
|
||||||
@@ -77,18 +79,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
|
|
||||||
return openDialogs.current.size > 0
|
return openDialogs.current.size > 0
|
||||||
} else {
|
} else {
|
||||||
BottomSheetNativeComponent.dismissAll()
|
void BottomSheetNativeComponent.dismissAll()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const setDialogIsOpen = useCallback((id: string, isOpen: boolean) => {
|
const setDialogIsOpen = useCallback(
|
||||||
|
(id: string, isOpen: boolean) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
openDialogs.current.add(id)
|
openDialogs.current.add(id)
|
||||||
} else {
|
} else {
|
||||||
openDialogs.current.delete(id)
|
openDialogs.current.delete(id)
|
||||||
}
|
}
|
||||||
}, [])
|
if (openDialogs.current.size > 0) {
|
||||||
|
disableScope('global')
|
||||||
|
} else {
|
||||||
|
enableScope('global')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disableScope, enableScope],
|
||||||
|
)
|
||||||
|
|
||||||
const context = useMemo<IDialogContext>(
|
const context = useMemo<IDialogContext>(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -45,3 +45,11 @@ export function listenPostCreated(fn: () => void): UnlistenFn {
|
|||||||
emitter.on('post-created', fn)
|
emitter.on('post-created', fn)
|
||||||
return () => emitter.off('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)
|
||||||
|
}
|
||||||
|
|||||||
+11
-1
@@ -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 {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {useHotkeysContext} from '#/lib/hotkeys'
|
||||||
import {type ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
|
import {type ImageSource} from '#/view/com/lightbox/ImageViewing/@types'
|
||||||
|
|
||||||
export type Lightbox = {
|
export type Lightbox = {
|
||||||
@@ -28,6 +29,15 @@ LightboxControlContext.displayName = 'LightboxControlContext'
|
|||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [activeLightbox, setActiveLightbox] = useState<Lightbox | null>(null)
|
const [activeLightbox, setActiveLightbox] = useState<Lightbox | null>(null)
|
||||||
|
const {disableScope, enableScope} = useHotkeysContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeLightbox) {
|
||||||
|
disableScope('global')
|
||||||
|
} else {
|
||||||
|
enableScope('global')
|
||||||
|
}
|
||||||
|
}, [activeLightbox, disableScope, enableScope])
|
||||||
|
|
||||||
const openLightbox = useNonReactiveCallback(
|
const openLightbox = useNonReactiveCallback(
|
||||||
(lightbox: Omit<Lightbox, 'id'>) => {
|
(lightbox: Omit<Lightbox, 'id'>) => {
|
||||||
|
|||||||
@@ -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 {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {useHotkeysContext} from '#/lib/hotkeys'
|
||||||
|
|
||||||
export interface UserAddRemoveListsModal {
|
export interface UserAddRemoveListsModal {
|
||||||
name: 'user-add-remove-lists'
|
name: 'user-add-remove-lists'
|
||||||
@@ -47,6 +48,15 @@ ModalControlContext.displayName = 'ModalControlContext'
|
|||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [activeModals, setActiveModals] = useState<Modal[]>([])
|
const [activeModals, setActiveModals] = useState<Modal[]>([])
|
||||||
|
const {disableScope, enableScope} = useHotkeysContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeModals.length > 0) {
|
||||||
|
disableScope('global')
|
||||||
|
} else {
|
||||||
|
enableScope('global')
|
||||||
|
}
|
||||||
|
}, [activeModals.length, disableScope, enableScope])
|
||||||
|
|
||||||
const openModal = useNonReactiveCallback((modal: Modal) => {
|
const openModal = useNonReactiveCallback((modal: Modal) => {
|
||||||
setActiveModals(modals => [...modals, modal])
|
setActiveModals(modals => [...modals, modal])
|
||||||
|
|||||||
@@ -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', <input> and <textarea> when readOnly state is false, <select>
|
|
||||||
if (
|
|
||||||
target.isContentEditable ||
|
|
||||||
((isInput || tagName === 'TEXTAREA' || tagName === 'SELECT') &&
|
|
||||||
!target.readOnly)
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useComposerKeyboardShortcut() {
|
|
||||||
const {openComposer} = useOpenComposer()
|
|
||||||
const {openDialogs} = useDialogStateContext()
|
|
||||||
const {isModalActive} = useModals()
|
|
||||||
const {activeLightbox} = useLightbox()
|
|
||||||
const isDrawerOpen = useIsDrawerOpen()
|
|
||||||
const {hasSession} = useSession()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasSession) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
function handler(event: KeyboardEvent) {
|
|
||||||
if (shouldIgnore(event)) return
|
|
||||||
if (
|
|
||||||
openDialogs?.current.size > 0 ||
|
|
||||||
isModalActive ||
|
|
||||||
activeLightbox ||
|
|
||||||
isDrawerOpen
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if (event.key === 'n' || event.key === 'N') {
|
|
||||||
openComposer({logContext: 'Other'})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handler)
|
|
||||||
return () => document.removeEventListener('keydown', handler)
|
|
||||||
}, [
|
|
||||||
openComposer,
|
|
||||||
isModalActive,
|
|
||||||
openDialogs,
|
|
||||||
activeLightbox,
|
|
||||||
isDrawerOpen,
|
|
||||||
hasSession,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import {createContext, useContext, useState} from 'react'
|
import {createContext, useContext, useState} from 'react'
|
||||||
|
|
||||||
|
import {useHotkeysContext} from '#/lib/hotkeys'
|
||||||
|
|
||||||
type StateContext = boolean
|
type StateContext = boolean
|
||||||
type SetContext = (v: boolean) => void
|
type SetContext = (v: boolean) => void
|
||||||
|
|
||||||
@@ -10,10 +12,22 @@ setContext.displayName = 'DrawerOpenSetContext'
|
|||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [state, setState] = useState(false)
|
const [state, setState] = useState(false)
|
||||||
|
const {disableScope, enableScope} = useHotkeysContext()
|
||||||
|
|
||||||
|
const setDrawerOpen = (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
disableScope('global')
|
||||||
|
} else {
|
||||||
|
enableScope('global')
|
||||||
|
}
|
||||||
|
setState(open)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<stateContext.Provider value={state}>
|
<stateContext.Provider value={state}>
|
||||||
<setContext.Provider value={setState}>{children}</setContext.Provider>
|
<setContext.Provider value={setDrawerOpen}>
|
||||||
|
{children}
|
||||||
|
</setContext.Provider>
|
||||||
</stateContext.Provider>
|
</stateContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ function ComposeBtn() {
|
|||||||
style={[a.rounded_full]}>
|
style={[a.rounded_full]}>
|
||||||
<ButtonIcon icon={EditBig} position="left" />
|
<ButtonIcon icon={EditBig} position="left" />
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans context="action">New Post</Trans>
|
<Trans context="action">New post</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function DesktopSearch() {
|
|||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
onClearText={onPressCancelSearch}
|
onClearText={onPressCancelSearch}
|
||||||
onSubmitEditing={onSubmit}
|
onSubmitEditing={onSubmit}
|
||||||
|
hotkey={true}
|
||||||
/>
|
/>
|
||||||
{tQuery !== '' && isActive && moderationOpts && (
|
{tQuery !== '' && isActive && moderationOpts && (
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
|||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
||||||
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {Lightbox} from '#/view/com/lightbox/Lightbox'
|
import {Lightbox} from '#/view/com/lightbox/Lightbox'
|
||||||
import {ModalsContainer} from '#/view/com/modals/Modal'
|
import {ModalsContainer} from '#/view/com/modals/Modal'
|
||||||
@@ -45,7 +44,6 @@ function ShellInner() {
|
|||||||
const {state: policyUpdateState} = usePolicyUpdateContext()
|
const {state: policyUpdateState} = usePolicyUpdateContext()
|
||||||
const welcomeModalControl = useWelcomeModal()
|
const welcomeModalControl = useWelcomeModal()
|
||||||
|
|
||||||
useComposerKeyboardShortcut()
|
|
||||||
useIntentHandler()
|
useIntentHandler()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13846,6 +13846,11 @@ react-freeze@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d"
|
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d"
|
||||||
integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g==
|
integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g==
|
||||||
|
|
||||||
|
react-hotkeys-hook@5.2.4:
|
||||||
|
version "5.2.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-hotkeys-hook/-/react-hotkeys-hook-5.2.4.tgz#45ad54d78823b2a929963d482aff98efad0530f2"
|
||||||
|
integrity sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==
|
||||||
|
|
||||||
react-image-crop@^11.0.7:
|
react-image-crop@^11.0.7:
|
||||||
version "11.0.7"
|
version "11.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.7.tgz#25f3d37ccbb65a05d19d23b4740a5912835c741e"
|
resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.7.tgz#25f3d37ccbb65a05d19d23b4740a5912835c741e"
|
||||||
|
|||||||
Reference in New Issue
Block a user