diff --git a/eslint.config.mjs b/eslint.config.mjs
index fc4474642d..a0f0db9140 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -127,6 +127,7 @@ export default defineConfig(
*/
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
+ 'react/hook-use-state': 'warn',
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react-native/no-inline-styles': 'off',
@@ -189,6 +190,18 @@ export default defineConfig(
*/
ignore: ['^#\/locale\/locales\/.+\/messages'],
}],
+ 'import-x/no-extraneous-dependencies': ['error', {
+ 'whitelist': [
+ // test files only
+ '@jest/globals',
+ // we only use a really simple util from this, and we know it will be present
+ 'expo-modules-core',
+ // this is a dep for @atproto/api, but we absolutely need them in sync, so just
+ // rely on the transient version
+ '@atproto/common-web',
+ ]
+ }],
+ 'import-x/no-nodejs-modules': 'error',
/**
* TypeScript-specific rules
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index f9bc36f6bf..6a6987c79d 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -9,6 +9,7 @@ jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
)
jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => {
+ // eslint-disable-next-line import-x/no-nodejs-modules
const {EventEmitter} = require('events')
return {
__esModule: true,
diff --git a/jest/test-utils.tsx b/jest/test-utils.tsx
deleted file mode 100644
index 264b31fae5..0000000000
--- a/jest/test-utils.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import {GestureHandlerRootView} from 'react-native-gesture-handler'
-import {SafeAreaProvider} from 'react-native-safe-area-context'
-import {render} from '@testing-library/react-native'
-
-import {ThemeProvider} from '../src/lib/ThemeContext'
-import {type RootStoreModel, RootStoreProvider} from '../src/state'
-
-const customRender = (ui: any, rootStore: RootStoreModel) =>
- render(
-
-
-
- {ui}
-
-
- ,
- )
-
-// re-export everything
-export * from '@testing-library/react-native'
-
-// override render method
-export {customRender as render}
diff --git a/package.json b/package.json
index 9e47e05ea1..81f9d5e4c0 100644
--- a/package.json
+++ b/package.json
@@ -81,7 +81,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.19.3",
+ "@atproto/api": "^0.19.5",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
@@ -116,9 +116,9 @@
"@react-navigation/native": "^7.1.33",
"@react-navigation/native-stack": "^7.14.4",
"@sentry/react-native": "~6.20.0",
- "@tanstack/query-async-storage-persister": "^5.95.2",
- "@tanstack/react-query": "^5.95.2",
- "@tanstack/react-query-persist-client": "^5.95.2",
+ "@tanstack/query-async-storage-persister": "^5.96.2",
+ "@tanstack/react-query": "^5.96.2",
+ "@tanstack/react-query-persist-client": "^5.96.2",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-document": "^2.9.1",
"@tiptap/extension-hard-break": "^2.9.1",
@@ -200,6 +200,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/ProfileBadges.tsx b/src/components/ProfileBadges.tsx
index dd0cedfe8f..22c682bbba 100644
--- a/src/components/ProfileBadges.tsx
+++ b/src/components/ProfileBadges.tsx
@@ -1,7 +1,7 @@
-import {View} from 'react-native'
+import {useWindowDimensions, View} from 'react-native'
import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {atoms as a, type ViewStyleProp} from '#/alf'
+import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -38,12 +38,21 @@ export function ProfileBadges({
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
+ const {fontScale: nativeScaleMultiplier} = useWindowDimensions()
+ const {
+ fonts: {scaleMultiplier: alfScaleMultiplier},
+ } = useAlf()
// if nothing to show, don't render the container at all
if (!verification.showBadge && !isBotAccount(shadowed)) return null
const isOnTheSmallSide = size === 'xs' || size === 'sm'
+ const verificationIconWidth =
+ verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
+ const botIconWidth =
+ botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
+
return (
-
+
>
) : (
<>
{verification.showBadge && (
)}
-
+
>
)}
diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
index bf79f5dfc4..15e3a2b472 100644
--- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
+++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
@@ -1,6 +1,6 @@
import {useState} from 'react'
import {View} from 'react-native'
-import {XRPCError} from '@atproto/xrpc'
+import {XRPCError} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
diff --git a/src/components/dialogs/EmailDialog/events.ts b/src/components/dialogs/EmailDialog/events.ts
index 4fa171cad5..d2c3810208 100644
--- a/src/components/dialogs/EmailDialog/events.ts
+++ b/src/components/dialogs/EmailDialog/events.ts
@@ -1,5 +1,5 @@
import {useEffect} from 'react'
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
const events = new EventEmitter<{
emailVerified: void
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/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx
index c750079d1e..83fe2e4019 100644
--- a/src/components/moderation/LabelsOnMeDialog.tsx
+++ b/src/components/moderation/LabelsOnMeDialog.tsx
@@ -1,7 +1,7 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {type ComAtprotoLabelDefs, ToolsOzoneReportDefs} from '@atproto/api'
-import {XRPCError} from '@atproto/xrpc'
+import {XRPCError} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
diff --git a/src/geolocation/service.ts b/src/geolocation/service.ts
index 2d9285b676..ec34747284 100644
--- a/src/geolocation/service.ts
+++ b/src/geolocation/service.ts
@@ -1,5 +1,5 @@
import {useEffect, useState} from 'react'
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
import {networkRetry} from '#/lib/async/retry'
import {
diff --git a/src/lib/hooks/useNavigationDeduped.ts b/src/lib/hooks/useNavigationDeduped.ts
index 2448787bd3..19d0c1c34f 100644
--- a/src/lib/hooks/useNavigationDeduped.ts
+++ b/src/lib/hooks/useNavigationDeduped.ts
@@ -1,5 +1,5 @@
import {useMemo} from 'react'
-import {useNavigation} from '@react-navigation/core'
+import {useNavigation} from '@react-navigation/native'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {type NavigationProp} from '#/lib/routes/types'
diff --git a/src/lib/hooks/useWebScrollRestoration.ts b/src/lib/hooks/useWebScrollRestoration.ts
index 5f60d5a592..bc79bc534e 100644
--- a/src/lib/hooks/useWebScrollRestoration.ts
+++ b/src/lib/hooks/useWebScrollRestoration.ts
@@ -1,5 +1,5 @@
import {useEffect, useMemo, useState} from 'react'
-import {type EventArg, useNavigation} from '@react-navigation/core'
+import {type EventArg, useNavigation} from '@react-navigation/native'
if ('scrollRestoration' in history) {
// Tell the brower not to mess with the scroll.
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/lib/media/manip.ts b/src/lib/media/manip.ts
index 6d66fea489..2be799e261 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -15,7 +15,6 @@ import {
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing'
-import {Buffer} from 'buffer'
import {POST_IMG_MAX} from '#/lib/constants'
import {logger} from '#/logger'
@@ -322,7 +321,12 @@ export async function saveBytesToDisk(
bytes: Uint8Array,
type: string,
) {
- const encoded = Buffer.from(bytes).toString('base64')
+ // ideally we'd use `bytes.toBase64()`, but that's only baseline newly available
+ let binary = ''
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte)
+ }
+ const encoded = btoa(binary)
return await saveToDevice(filename, encoded, type)
}
diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx
index ec657b6c41..b69e715056 100644
--- a/src/lib/react-query.tsx
+++ b/src/lib/react-query.tsx
@@ -190,7 +190,10 @@ function QueryProviderInner({
})
useEffect(() => {
if (IS_WEB) {
- window.__TANSTACK_QUERY_CLIENT__ = queryClient
+ // WARNING, BROKEN
+ // something since v5.32.0 causes OOMs. not important
+ // so disable for now
+ // window.__TANSTACK_QUERY_CLIENT__ = queryClient
}
}, [queryClient])
return (
diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts
index f3a91e58dd..5f56cccc4d 100644
--- a/src/lib/strings/errors.ts
+++ b/src/lib/strings/errors.ts
@@ -1,4 +1,4 @@
-import {XRPCError} from '@atproto/xrpc'
+import {XRPCError} from '@atproto/api'
import {t} from '@lingui/core/macro'
export function cleanError(str: any): string {
diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po
index bae0b9ec75..a26cb20b0b 100644
--- a/src/locale/locales/en/messages.po
+++ b/src/locale/locales/en/messages.po
@@ -1953,7 +1953,7 @@ msgstr ""
#: src/screens/Deactivated.tsx:150
#: src/screens/Profile/Header/EditProfileDialog.tsx:215
#: src/screens/Profile/Header/EditProfileDialog.tsx:223
-#: src/screens/Search/Shell.tsx:396
+#: src/screens/Search/Shell.tsx:397
#: src/screens/Settings/AppIconSettings/index.tsx:42
#: src/screens/Settings/AppIconSettings/index.tsx:228
#: src/screens/Settings/components/ChangeHandleDialog.tsx:80
@@ -1979,7 +1979,7 @@ msgstr ""
msgid "Cancel reactivation and sign out"
msgstr ""
-#: src/screens/Search/Shell.tsx:387
+#: src/screens/Search/Shell.tsx:388
msgid "Cancel search"
msgstr ""
@@ -2218,7 +2218,7 @@ msgstr ""
msgid "Clear image cache"
msgstr ""
-#: src/components/forms/SearchInput.tsx:69
+#: src/components/forms/SearchInput.tsx:87
msgid "Clear search query"
msgstr ""
@@ -2338,7 +2338,7 @@ msgstr ""
msgid "Close dialog"
msgstr ""
-#: src/view/shell/index.web.tsx:131
+#: src/view/shell/index.web.tsx:129
msgid "Close drawer menu"
msgstr ""
@@ -2430,6 +2430,7 @@ msgstr ""
msgid "Complete the challenge"
msgstr ""
+#: src/lib/hotkeys/index.tsx:67
#: src/view/com/feeds/ComposerPrompt.tsx:147
#: src/view/shell/desktop/LeftNav.tsx:575
msgid "Compose new post"
@@ -4349,7 +4350,7 @@ msgstr ""
msgid "Find people to follow"
msgstr ""
-#: src/screens/Search/Shell.tsx:529
+#: src/screens/Search/Shell.tsx:530
msgid "Find posts, users, and feeds on Bluesky"
msgstr ""
@@ -4393,6 +4394,10 @@ msgstr ""
msgid "Focus code input"
msgstr ""
+#: src/lib/hotkeys/index.tsx:74
+msgid "Focus the search field"
+msgstr "Focus the search field"
+
#. User is not following this account, click to follow
#: src/components/ProfileCard.tsx:546
#: src/components/ProfileHoverCard/index.web.tsx:495
@@ -6485,13 +6490,9 @@ msgid "New post"
msgstr ""
#: src/view/com/feeds/FeedPage.tsx:181
-msgctxt "action"
-msgid "New post"
-msgstr ""
-
#: src/view/shell/desktop/LeftNav.tsx:583
msgctxt "action"
-msgid "New Post"
+msgid "New post"
msgstr ""
#: src/view/com/notifications/NotificationFeedItem.tsx:545
@@ -8731,10 +8732,10 @@ msgid "Scroll to top"
msgstr ""
#: src/components/dialogs/SearchablePeopleList.tsx:515
-#: src/components/forms/SearchInput.tsx:33
-#: src/components/forms/SearchInput.tsx:35
+#: src/components/forms/SearchInput.tsx:51
+#: src/components/forms/SearchInput.tsx:53
#: src/screens/Search/Shell.tsx:354
-#: src/screens/Search/Shell.tsx:517
+#: src/screens/Search/Shell.tsx:518
#: src/view/shell/bottom-bar/BottomBar.tsx:199
msgid "Search"
msgstr ""
@@ -8772,7 +8773,7 @@ msgstr ""
msgid "Search for \"{searchText}\""
msgstr ""
-#: src/view/shell/desktop/Search.tsx:128
+#: src/view/shell/desktop/Search.tsx:129
msgid "Search for “{tQuery}”"
msgstr "Search for “{tQuery}”"
@@ -9634,7 +9635,7 @@ msgstr ""
msgid "Sorry, we're unable to load account suggestions at this time."
msgstr ""
-#: src/App.native.tsx:142
+#: src/App.native.tsx:141
#: src/App.web.tsx:119
msgid "Sorry! Your session expired. Please sign in again."
msgstr ""
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/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index 0afa272c53..f01a7a081d 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -5,7 +5,7 @@ import {
type AppBskyFeedDefs,
} from '@atproto/api'
import {type QueryClient} from '@tanstack/react-query'
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery'
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 34422dc0c0..b8a6c58c35 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState} from 'react'
import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api'
import {type QueryClient} from '@tanstack/react-query'
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions'
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 dcd36464ec..87a1fab705 100644
--- a/src/state/events.ts
+++ b/src/state/events.ts
@@ -1,4 +1,4 @@
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
type UnlistenFn = () => void
@@ -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/global-gesture-events/index.tsx b/src/state/global-gesture-events/index.tsx
index 2f0d652210..4d3e9795dc 100644
--- a/src/state/global-gesture-events/index.tsx
+++ b/src/state/global-gesture-events/index.tsx
@@ -7,7 +7,7 @@ import {
type GestureUpdateEvent,
type PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler'
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
export type GlobalGestureEvents = {
begin: GestureStateChangeEvent
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/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index d29049c872..b6c8ee2f16 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -5,8 +5,8 @@ import {
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
} from '@atproto/api'
-import {XRPCError} from '@atproto/xrpc'
-import EventEmitter from 'eventemitter3'
+import {XRPCError} from '@atproto/api'
+import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
import {networkRetry} from '#/lib/async/retry'
diff --git a/src/state/messages/events/agent.ts b/src/state/messages/events/agent.ts
index e8404fd000..ce9518212b 100644
--- a/src/state/messages/events/agent.ts
+++ b/src/state/messages/events/agent.ts
@@ -1,5 +1,5 @@
import {type BskyAgent, type ChatBskyConvoGetLog} from '@atproto/api'
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
import {networkRetry} from '#/lib/async/retry'
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/persisted/index.web.ts b/src/state/persisted/index.web.ts
index ff278b74be..35e796810d 100644
--- a/src/state/persisted/index.web.ts
+++ b/src/state/persisted/index.web.ts
@@ -1,4 +1,4 @@
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index ce6e209386..bf7505f91b 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -12,7 +12,7 @@ import {
} from 'react'
import {AppState} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
-import EventEmitter from 'eventemitter3'
+import {EventEmitter} from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {resetBadgeCount} from '#/lib/notifications/notifications'
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