From 68a4d73d61c46bd0fc205ebaa016e2e38912b50c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 2 Apr 2026 08:34:12 -0700 Subject: [PATCH 1/7] Add `no-extraneous-dependencies` and `no-nodejs-modules` eslint rules (#10151) --- eslint.config.mjs | 12 +++++ jest/jestSetup.js | 1 + jest/test-utils.tsx | 23 -------- package.json | 2 +- .../ageAssurance/AgeAssuranceInitDialog.tsx | 2 +- src/components/dialogs/EmailDialog/events.ts | 2 +- .../moderation/LabelsOnMeDialog.tsx | 2 +- src/geolocation/service.ts | 2 +- src/lib/hooks/useNavigationDeduped.ts | 2 +- src/lib/hooks/useWebScrollRestoration.ts | 2 +- src/lib/media/manip.ts | 8 ++- src/lib/strings/errors.ts | 2 +- src/state/cache/post-shadow.ts | 2 +- src/state/cache/profile-shadow.ts | 2 +- src/state/events.ts | 2 +- src/state/global-gesture-events/index.tsx | 2 +- src/state/messages/convo/agent.ts | 4 +- src/state/messages/events/agent.ts | 2 +- src/state/persisted/index.web.ts | 2 +- src/state/queries/notifications/unread.tsx | 2 +- .../text-input/textInputWebEmitter.ts | 2 +- src/view/com/util/MainScrollProvider.tsx | 2 +- src/view/shell/desktop/RightNav.tsx | 2 +- src/view/shell/index.web.tsx | 2 +- yarn.lock | 52 +++++++++---------- 25 files changed, 66 insertions(+), 72 deletions(-) delete mode 100644 jest/test-utils.tsx diff --git a/eslint.config.mjs b/eslint.config.mjs index fc4474642d..abfdd4a013 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -189,6 +189,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 36f7ec6152..1871a8e0af 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", 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/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/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/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/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/events.ts b/src/state/events.ts index dcd36464ec..2ab72465de 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 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/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/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/view/com/composer/text-input/textInputWebEmitter.ts b/src/view/com/composer/text-input/textInputWebEmitter.ts index fb037cac2e..c889e12f57 100644 --- a/src/view/com/composer/text-input/textInputWebEmitter.ts +++ b/src/view/com/composer/text-input/textInputWebEmitter.ts @@ -1,3 +1,3 @@ -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' export const textInputWebEmitter = new EventEmitter() diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index a49a798983..3038ad21e0 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -7,7 +7,7 @@ import { withSpring, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import EventEmitter from 'eventemitter3' +import {EventEmitter} from 'eventemitter3' import {ScrollProvider} from '#/lib/ScrollContext' import {useMinimalShellMode} from '#/state/shell' diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index d50a4bb679..07ea25632a 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useNavigation} from '@react-navigation/core' +import {useNavigation} from '@react-navigation/native' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' import {useKawaiiMode} from '#/state/preferences/kawaii' diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index a7f255570e..ce13986524 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -36,7 +36,7 @@ import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' import {FlatNavigator, RoutesContainer} from '#/Navigation' -import {Composer} from './Composer.web' +import {Composer} from './Composer' import {DrawerContent} from './Drawer' function ShellInner() { diff --git a/yarn.lock b/yarn.lock index 4a5d8dc52e..ca998282a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,46 +20,46 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@atproto/api@^0.19.3": - version "0.19.3" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.3.tgz#61de8d2e31abe9eb2b4c8f4ad124ed79d4a77e89" - integrity sha512-G8YpBpRouHdTAIagi/QQIUZOhGd1jfBQWkJy9QfxAzjjEpPvaVOSk4e1S85QzGLm/xbzVONzGkmdtiOSfP6wVg== +"@atproto/api@^0.19.5": + version "0.19.5" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.19.5.tgz#6388e5d6d3a1693fe04b5f37c705682bac8601d3" + integrity sha512-u6R5TecYJDO8l8QFN09AMuJASYnUkJ4HhYE5hg4/dha/z14a+OAil2/dli/208uM5AHPFLtlnB8kIK9XU5GgQQ== dependencies: - "@atproto/common-web" "^0.4.18" + "@atproto/common-web" "^0.4.19" "@atproto/lexicon" "^0.6.2" - "@atproto/syntax" "^0.5.0" + "@atproto/syntax" "^0.5.2" "@atproto/xrpc" "^0.7.7" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" zod "^3.23.8" -"@atproto/common-web@^0.4.18": - version "0.4.18" - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.18.tgz#832976340457afd3d29345ad2c6f0ac0bff087dd" - integrity sha512-ilImzP+9N/mtse440kN60pGrEzG7wi4xsV13nGeLrS+Zocybc/ISOpKlbZM13o+twPJ+Q7veGLw9CtGg0GAFoQ== +"@atproto/common-web@^0.4.18", "@atproto/common-web@^0.4.19": + version "0.4.19" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.19.tgz#bbd7f84f545ebe73ca3bc00314ccf4ee66e7069e" + integrity sha512-3BTi58p5WpT+9/zb6UZrdsXcfPo5P45UJm0E4iwHLILr+jc37CuBj9JReDSZ4U0i9RTrI3ZkfySyZ9bd+LnMsw== dependencies: - "@atproto/lex-data" "^0.0.13" - "@atproto/lex-json" "^0.0.13" - "@atproto/syntax" "^0.5.0" + "@atproto/lex-data" "^0.0.14" + "@atproto/lex-json" "^0.0.14" + "@atproto/syntax" "^0.5.1" zod "^3.23.8" -"@atproto/lex-data@^0.0.13": - version "0.0.13" - resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.13.tgz#db1bcfa12d5056210f6eb7f3b8bac909909d6b9c" - integrity sha512-7Z7RwZ1Y/JzBF/Tcn/I4UJ/vIGfh5zn1zjv0KX+flke2JtgFkSE8uh2hOtqgBQMNqE3zdJFM+dcSWln86hR3MQ== +"@atproto/lex-data@^0.0.14": + version "0.0.14" + resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.14.tgz#2f2f3c64699925a0d4785e5afd0e7731ba1d46c0" + integrity sha512-53DUa9664SS76nGAMYopWsO10OH0AAdf7P/HSKB6Wzx3iqe6lk/K61QZnKxOG1LreYl5CfvIJU6eNf4txI6GlQ== dependencies: multiformats "^9.9.0" tslib "^2.8.1" uint8arrays "3.0.0" unicode-segmenter "^0.14.0" -"@atproto/lex-json@^0.0.13": - version "0.0.13" - resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.13.tgz#b0081f786aeeb1707087318fb03c928e75c19059" - integrity sha512-hwLhkKaIHulGJpt0EfXAEWdrxqM2L1tV/tvilzhMp3QxPqYgXchFnrfVmLsyFDx6P6qkH1GsX/XC2V36U0UlPQ== +"@atproto/lex-json@^0.0.14": + version "0.0.14" + resolved "https://registry.yarnpkg.com/@atproto/lex-json/-/lex-json-0.0.14.tgz#717e533ab583aa5f580acb2a77d9aa3e7eddaa17" + integrity sha512-6lPkDKqe7teEu4WrN5q7400cvZKgYS3uwUMvzG3F9XkgVYhOwSDCtouV/nSLBbpvo3l9OP0kiigtclcNcyekww== dependencies: - "@atproto/lex-data" "^0.0.13" + "@atproto/lex-data" "^0.0.14" tslib "^2.8.1" "@atproto/lexicon@^0.6.0", "@atproto/lexicon@^0.6.2": @@ -73,10 +73,10 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/syntax@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.0.tgz#061ef538aee784f8e5fa1ea50a7f5beb4c276c9b" - integrity sha512-UA2DSpGdOQzUQ4gi5SH+NEJz/YR3a3Fg3y2oh+xETDSiTRmA4VhHRCojhXAVsBxUT6EnItw190C/KN+DWW90kw== +"@atproto/syntax@^0.5.0", "@atproto/syntax@^0.5.1", "@atproto/syntax@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.5.2.tgz#d4b32c9feb421ceeb5ade1fa80bc42764d51e52e" + integrity sha512-W41szOnkppoHr0iCUrzL8gy3OD6qmDyp1UvUgmTx2oFQfgbudpz51T/gznesiCcqiUT5obfHdx4PJ+WdlEOE7Q== dependencies: tslib "^2.8.1" From b9f3d04d65b0c639a4ad5d59c38cb5435ffe74e3 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 2 Apr 2026 11:51:38 -0700 Subject: [PATCH 2/7] Scale profile badge icons with font size (#10161) --- src/components/ProfileBadges.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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 && ( )} - + )} From 9fe808f8a8962b2bdc9ee3638ad231c5b0fbce7b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 2 Apr 2026 11:51:54 -0700 Subject: [PATCH 3/7] Fix bottom bar badge text padding (#10162) --- src/view/shell/bottom-bar/BottomBar.tsx | 10 ++++++++-- src/view/shell/bottom-bar/BottomBarStyles.tsx | 12 ++++++------ src/view/shell/bottom-bar/BottomBarWeb.tsx | 4 +++- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index a1386c644c..8c1fb3c52b 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -400,10 +400,16 @@ function Btn({ a.rounded_full, {backgroundColor: t.palette.primary_500}, ]}> - {notificationCount} + + {notificationCount} + ) : hasNew ? ( - + ) : null} ) diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index 3c99eaf6f6..2602862dcb 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -1,6 +1,5 @@ import {StyleSheet} from 'react-native' -import {colors} from '#/lib/styles' import {atoms as a} from '#/alf' export const styles = StyleSheet.create({ @@ -24,8 +23,9 @@ export const styles = StyleSheet.create({ position: 'absolute', left: '52%', top: 8, - paddingHorizontal: 4, - paddingBottom: 1, + paddingHorizontal: 5, + paddingTop: 1, + paddingBottom: 2, borderRadius: 6, zIndex: 1, }, @@ -37,8 +37,9 @@ export const styles = StyleSheet.create({ notificationCountLabel: { fontSize: 12, fontWeight: '600', - color: colors.white, + color: 'white', fontVariant: ['tabular-nums'], + includeFontPadding: false, }, hasNewBadge: { position: 'absolute', @@ -47,8 +48,7 @@ export const styles = StyleSheet.create({ top: 10, width: 8, height: 8, - backgroundColor: colors.blue3, - borderRadius: 6, + borderRadius: 4, zIndex: 1, }, ctrlIcon: { diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index e0d1edd66a..2bf9807e43 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -313,7 +313,9 @@ const NavItem: React.FC<{ {notificationCount} ) : hasNew ? ( - + ) : null} ) From e0ea778e585807b24beb07bd8ab75a61131522ce Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:47:56 -0700 Subject: [PATCH 4/7] Warn when useState get/set names mismatch (#10166) --- eslint.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index abfdd4a013..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', From cca3326b21540ca2973793517ba05c00f43f734b Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:06:51 -0700 Subject: [PATCH 5/7] Create global keyboard shortcut handler (#10145) --- package.json | 1 + src/App.native.tsx | 27 ++-- src/App.web.tsx | 32 ++-- src/components/forms/SearchInput.tsx | 141 ++++++++++-------- src/lib/hotkeys/index.tsx | 76 ++++++++++ src/screens/Search/Shell.tsx | 1 + src/state/dialogs/index.tsx | 26 +++- src/state/events.ts | 8 + src/state/lightbox.tsx | 12 +- src/state/modals/index.tsx | 12 +- .../composer/useComposerKeyboardShortcut.tsx | 77 ---------- src/state/shell/drawer-open.tsx | 16 +- src/view/shell/desktop/LeftNav.tsx | 2 +- src/view/shell/desktop/Search.tsx | 1 + src/view/shell/index.web.tsx | 2 - yarn.lock | 5 + 16 files changed, 257 insertions(+), 182 deletions(-) create mode 100644 src/lib/hotkeys/index.tsx delete mode 100644 src/state/shell/composer/useComposerKeyboardShortcut.tsx 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