diff --git a/package.json b/package.json
index 501f2f6b0..80705c3a9 100644
--- a/package.json
+++ b/package.json
@@ -124,6 +124,7 @@
"@lingui/react": "^5.9.5",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.1",
+ "@noble/hashes": "^2.2.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.9",
"@react-navigation/native": "^7.2.2",
@@ -288,8 +289,8 @@
"@types/psl": "^1.11.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^6.0.1",
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
+ "@vitejs/plugin-react": "^6.0.1",
"babel-jest": "^29.7.0",
"babel-loader": "^10.1.1",
"babel-plugin-module-resolver": "^5.0.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 785e7e3bf..cd7ebebdb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -218,6 +218,9 @@ importers:
'@mozzius/expo-dynamic-app-icon':
specifier: ^1.8.1
version: 1.8.1(expo@54.0.33(@babel/core@7.25.2)(react-native-webview@13.16.1(react-native@0.81.5(patch_hash=1708bf9fa9265ebd463d53fa71c037e9387eb16fb483287e49616407b4dc342e)(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=1708bf9fa9265ebd463d53fa71c037e9387eb16fb483287e49616407b4dc342e)(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=1708bf9fa9265ebd463d53fa71c037e9387eb16fb483287e49616407b4dc342e)(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
+ '@noble/hashes':
+ specifier: ^2.2.0
+ version: 2.2.0
'@react-native-async-storage/async-storage':
specifier: 2.2.0
version: 2.2.0(react-native@0.81.5(patch_hash=1708bf9fa9265ebd463d53fa71c037e9387eb16fb483287e49616407b4dc342e)(@babel/core@7.25.2)(@types/react@19.2.14)(react@19.1.0))
@@ -3302,6 +3305,10 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
+ '@noble/hashes@2.2.0':
+ resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
+ engines: {node: '>= 20.19.0'}
+
'@noble/secp256k1@1.7.2':
resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==}
@@ -16524,6 +16531,8 @@ snapshots:
'@noble/hashes@1.8.0': {}
+ '@noble/hashes@2.2.0': {}
+
'@noble/secp256k1@1.7.2': {}
'@nodelib/fs.scandir@2.1.5':
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 7c8beb13b..3d80abe4f 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -78,11 +78,11 @@ import {
setupDeviceId,
} from '#/analytics'
import {IS_ANDROID, IS_IOS} from '#/env'
-import {SettingsSyncGate} from '#/features/settingsSync'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
+import {SettingsSyncGate} from '#/features/settingsSync'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx
index 0af20ac30..d02288bf9 100644
--- a/src/components/PostControls/index.tsx
+++ b/src/components/PostControls/index.tsx
@@ -6,7 +6,7 @@ import {
type AppBskyFeedThreadgate,
type RichText as RichTextAPI,
} from '@atproto/api'
-import {msg, plural} from '@lingui/core/macro'
+import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {CountWheel} from '#/lib/custom-animations/CountWheel'
@@ -31,14 +31,14 @@ import {
} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher'
import {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton'
import * as Toast from '#/components/Toast'
-import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher'
import {useAnalytics} from '#/analytics'
-import {useRunWithEphemeralAgent} from '../hooks/useRunWithEphemeralAgent'
import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx'
+import {useRunWithEphemeralAgent} from '../hooks/useRunWithEphemeralAgent'
import {BookmarkButton} from './BookmarkButton'
import {
PostControlButton,
@@ -108,8 +108,8 @@ let PostControls = ({
const playHaptic = useHaptics()
const isBlocked = Boolean(
post.author.viewer?.blocking ||
- post.author.viewer?.blockedBy ||
- post.author.viewer?.blockingByList,
+ post.author.viewer?.blockedBy ||
+ post.author.viewer?.blockingByList,
)
const replyDisabled = post.viewer?.replyDisabled
const {gtPhone} = useBreakpoints()
@@ -339,21 +339,24 @@ let PostControls = ({
account: (typeof accounts)[number],
) => {
try {
- const wasBookmarked = await runWithEphemeralAgent(account, async agent => {
- const res = await agent.getPosts({uris: [post.uri]})
- const target = res.data.posts[0]
-
- if (target?.viewer?.bookmarked) {
- await agent.app.bsky.bookmark.deleteBookmark({uri: post.uri})
- return true
- }
-
- await agent.app.bsky.bookmark.createBookmark({
- uri: post.uri,
- cid: post.cid,
- })
- return false
- })
+ const wasBookmarked = await runWithEphemeralAgent(
+ account,
+ async agent => {
+ const res = await agent.getPosts({uris: [post.uri]})
+ const target = res.data.posts[0]
+
+ if (target?.viewer?.bookmarked) {
+ await agent.app.bsky.bookmark.deleteBookmark({uri: post.uri})
+ return true
+ }
+
+ await agent.app.bsky.bookmark.createBookmark({
+ uri: post.uri,
+ cid: post.cid,
+ })
+ return false
+ },
+ )
Toast.show(
wasBookmarked
@@ -426,62 +429,25 @@ let PostControls = ({
style,
]}>
-
- {currentAccount && hasAlternateAccounts && !replyDisabled ? (
- {
- onReplyAsAccount(account.did)
- }}
- renderTrigger={({triggerProps}) => (
-
- requireAuth(() => {
- ax.metric('post:clickReply', {
- uri: post.uri,
- authorDid: post.author.did,
- logContext,
- feedDescriptor,
- })
- onPressReply()
- })
- }
- onLongPress={triggerProps.onLongPress}
- label={l({
- message: `Reply (${plural(post.replyCount || 0, {
- one: '# reply',
- other: '# replies',
- })})`,
- comment:
- 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
- })}
- big={big}>
-
- {typeof post.replyCount !== 'undefined' &&
- post.replyCount > 0 &&
- !disableReplyMetrics && (
-
- {formatPostStatCount(post.replyCount)}
-
- )}
-
- )}
- />
- ) : (
-
+
+ {currentAccount && hasAlternateAccounts && !replyDisabled ? (
+ {
+ onReplyAsAccount(account.did)
+ }}
+ renderTrigger={({triggerProps}) => (
+
requireAuth(() => {
ax.metric('post:clickReply', {
uri: post.uri,
@@ -491,28 +457,65 @@ let PostControls = ({
})
onPressReply()
})
- : undefined
- }
- label={l({
- message: `Reply (${plural(post.replyCount || 0, {
- one: '# reply',
- other: '# replies',
- })})`,
- comment:
- 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
- })}
- big={big}>
-
- {typeof post.replyCount !== 'undefined' &&
- post.replyCount > 0 &&
- !disableReplyMetrics && (
-
- {formatPostStatCount(post.replyCount)}
-
+ }
+ onLongPress={triggerProps.onLongPress}
+ label={l({
+ message: `Reply (${plural(post.replyCount || 0, {
+ one: '# reply',
+ other: '# replies',
+ })})`,
+ comment:
+ 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
+ })}
+ big={big}>
+
+ {typeof post.replyCount !== 'undefined' &&
+ post.replyCount > 0 &&
+ !disableReplyMetrics && (
+
+ {formatPostStatCount(post.replyCount)}
+
+ )}
+
)}
-
- )}
-
+ />
+ ) : (
+
+ requireAuth(() => {
+ ax.metric('post:clickReply', {
+ uri: post.uri,
+ authorDid: post.author.did,
+ logContext,
+ feedDescriptor,
+ })
+ onPressReply()
+ })
+ : undefined
+ }
+ label={l({
+ message: `Reply (${plural(post.replyCount || 0, {
+ one: '# reply',
+ other: '# replies',
+ })})`,
+ comment:
+ 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
+ })}
+ big={big}>
+
+ {typeof post.replyCount !== 'undefined' &&
+ post.replyCount > 0 &&
+ !disableReplyMetrics && (
+
+ {formatPostStatCount(post.replyCount)}
+
+ )}
+
+ )}
+
{currentAccount && hasAlternateAccounts ? (
)}
-
+
+ {currentAccount && hasAlternateAccounts ? (
+ {
+ void onSelectLikeAccount(account)
+ }}
+ renderTrigger={({triggerProps}) =>
+ renderLikeButton(triggerProps.onLongPress)
+ }
+ />
+ ) : (
+ renderLikeButton()
+ )}
+
+ {/* Spacer! */}
+
+
+
{currentAccount && hasAlternateAccounts ? (
{
- void onSelectLikeAccount(account)
+ void onSelectBookmarkAccount(account)
}}
- renderTrigger={({triggerProps}) =>
- renderLikeButton(triggerProps.onLongPress)
- }
+ renderTrigger={({triggerProps}) => (
+
+ )}
/>
) : (
- renderLikeButton()
+
)}
-
- {/* Spacer! */}
-
-
-
- {currentAccount && hasAlternateAccounts ? (
- {
- void onSelectBookmarkAccount(account)
- }}
- renderTrigger={({triggerProps}) => (
-
- )}
- />
- ) : (
-
- )}
-
{
+ /* eslint-disable react-hooks/preserve-manual-memoization -- restored stable handlers */
+ const prefetchIfNeeded = useCallback(async () => {
if (!prefetchedProfile.current) {
prefetchedProfile.current = true
prefetchProfileQuery(props.did)
}
- }
+ }, [prefetchProfileQuery, props.did])
const didFireHover = useRef(false)
- const onPointerMoveTarget = () => {
+ const onPointerMoveTarget = useCallback(() => {
prefetchIfNeeded()
// Conceptually we want something like onPointerEnter,
// but we want to ignore entering only due to scrolling.
@@ -295,24 +296,26 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
didFireHover.current = true
dispatch('hovered-target')
}
- }
+ }, [prefetchIfNeeded])
- const onPointerLeaveTarget = () => {
+ const onPointerLeaveTarget = useCallback(() => {
didFireHover.current = false
dispatch('unhovered-target')
- }
+ }, [])
- const onPointerEnterCard = () => {
+ const onPointerEnterCard = useCallback(() => {
dispatch('hovered-card')
- }
+ }, [])
- const onPointerLeaveCard = () => {
+ const onPointerLeaveCard = useCallback(() => {
dispatch('unhovered-card')
- }
+ }, [])
- const onPress = () => {
+ const onPress = useCallback(() => {
dispatch('pressed')
- }
+ }, [])
+
+ /* eslint-enable react-hooks/preserve-manual-memoization */
const isVisible =
currentState.stage === 'showing' ||
@@ -371,13 +374,16 @@ let Card = ({
const status = useActorStatus(data)
- const onPressOpenProfile = () => {
+ /* eslint-disable react-hooks/preserve-manual-memoization -- restored stable handler */
+ const onPressOpenProfile = useCallback(() => {
if (!status.isActive || !data) return
hide()
navigation.push('Profile', {
name: data.handle,
})
- }
+ }, [hide, navigation, status, data])
+
+ /* eslint-enable react-hooks/preserve-manual-memoization */
return (
{
- return word.value === tag
- }) || []
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization -- restored memoization
+ const removeableMuteWords = useMemo(() => {
+ return (
+ preferences?.moderationPrefs.mutedWords?.filter(word => {
+ return word.value === tag
+ }) || []
+ )
+ }, [tag, preferences?.moderationPrefs?.mutedWords])
return (
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx
index 420aae8b2..253a3d533 100644
--- a/src/components/dialogs/PostInteractionSettingsDialog.tsx
+++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx
@@ -352,7 +352,8 @@ export function PostInteractionSettingsForm({
v => v.type === 'list',
).length
- const toggleGroupValues = (() => {
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization -- restored memoization
+ const toggleGroupValues = useMemo(() => {
const values: string[] = []
for (const setting of threadgateAllowUISettings) {
switch (setting.type) {
@@ -377,7 +378,7 @@ export function PostInteractionSettingsForm({
}
}
return values
- })()
+ }, [threadgateAllowUISettings])
const toggleGroupOnChange = (values: string[]) => {
const settings: ThreadgateAllowUISetting[] = []
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx
index a604f04b1..54925a204 100644
--- a/src/components/moderation/ContentHider.tsx
+++ b/src/components/moderation/ContentHider.tsx
@@ -1,4 +1,4 @@
-import {useState} from 'react'
+import {useMemo, useState} from 'react'
import {
LayoutAnimation,
type StyleProp,
@@ -88,7 +88,8 @@ function ContentHiderActive({
const blur = modui?.blurs[0]
const desc = useModerationCauseDescription(blur)
- const labelName = (() => {
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization -- restored memoization
+ const labelName = useMemo(() => {
if (!modui?.blurs || !blur) {
return undefined
}
@@ -137,7 +138,16 @@ function ContentHiderActive({
return desc.name
}
return [...new Set(selfBlurNames)].join(', ')
- })()
+ }, [
+ _,
+ modui?.blurs,
+ blur,
+ desc.name,
+ desc.isSubjectAccount,
+ labelDefs,
+ i18n.locale,
+ globalLabelStrings,
+ ])
return (
diff --git a/src/lib/storage-manifest/codec.ts b/src/lib/storage-manifest/codec.ts
index c96db16cf..9421bf251 100644
--- a/src/lib/storage-manifest/codec.ts
+++ b/src/lib/storage-manifest/codec.ts
@@ -20,8 +20,8 @@
* manifest is self-authenticating.
*/
+import {sha256} from '@noble/hashes/sha2.js'
import {gzip, inflate} from 'pako'
-import {sha256} from '@noble/hashes/sha256'
// ---------------------------------------------------------------------------
// Constants
@@ -152,9 +152,7 @@ export function decode(segments: string[]): unknown {
const partial = lines.slice(0, -1).join('\n')
// 1. Verify manifestHash
- const expectedManifestHash = toHex(
- sha256(new TextEncoder().encode(partial)),
- )
+ const expectedManifestHash = toHex(sha256(new TextEncoder().encode(partial)))
if (expectedManifestHash !== manifestHashField) {
throw new Error('storage-manifest: manifestHash mismatch')
}
@@ -194,7 +192,8 @@ export function decode(segments: string[]): unknown {
}
// Trim any padding byte that u15 decoding may have appended
- const compressed = decoded.length === bytes ? decoded : decoded.subarray(0, bytes)
+ const compressed =
+ decoded.length === bytes ? decoded : decoded.subarray(0, bytes)
// 6. sha256 check
const actualHash = toHex(sha256(compressed))
@@ -207,14 +206,16 @@ export function decode(segments: string[]): unknown {
try {
jsonBytes = inflate(compressed)
} catch (e) {
- throw new Error(`storage-manifest: decompression failed: ${e}`)
+ const detail = e instanceof Error ? e.message : String(e)
+ throw new Error(`storage-manifest: decompression failed: ${detail}`)
}
// 8. Parse
try {
return JSON.parse(new TextDecoder().decode(jsonBytes))
} catch (e) {
- throw new Error(`storage-manifest: JSON parse failed: ${e}`)
+ const detail = e instanceof Error ? e.message : String(e)
+ throw new Error(`storage-manifest: JSON parse failed: ${detail}`)
}
}
diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx
index 11a960f7a..88a1510ce 100644
--- a/src/screens/PostThread/index.tsx
+++ b/src/screens/PostThread/index.tsx
@@ -83,7 +83,8 @@ export function PostThread({uri}: {uri: string}) {
* One query to rule them all
*/
const thread = usePostThread({anchor: uri})
- const {anchor, hasParents} = (() => {
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization -- restored memoization
+ const {anchor, hasParents} = useMemo(() => {
let hasParents = false
for (const item of thread.data.items) {
if (item.type === 'threadPost' && item.depth === 0) {
@@ -92,7 +93,7 @@ export function PostThread({uri}: {uri: string}) {
hasParents = true
}
return {hasParents}
- })()
+ }, [thread.data.items])
// Track post:view event when anchor post is viewed
const seenPostUriRef = useRef(null)
diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx
index 0e0657f43..1b9bf3af2 100644
--- a/src/screens/Profile/Header/index.tsx
+++ b/src/screens/Profile/Header/index.tsx
@@ -17,8 +17,8 @@ import {
import {useIsFocused} from '@react-navigation/native'
import {sanitizeHandle} from '#/lib/strings/handles'
-import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
diff --git a/src/screens/Settings/AIPreferencesSettings.tsx b/src/screens/Settings/AIPreferencesSettings.tsx
index 1369da78c..d2ae123f6 100644
--- a/src/screens/Settings/AIPreferencesSettings.tsx
+++ b/src/screens/Settings/AIPreferencesSettings.tsx
@@ -5,8 +5,8 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {
AI_PREFERENCE_CATEGORIES,
- preferenceSetToTriStates,
type AIPreferenceCategory,
+ preferenceSetToTriStates,
type TriState,
useAIPreferencesQuery,
useUpdateAIPreferencesMutation,
diff --git a/src/screens/Settings/RunesSettings/SettingsSyncSettings.tsx b/src/screens/Settings/RunesSettings/SettingsSyncSettings.tsx
index 1ab3fad78..8c816f747 100644
--- a/src/screens/Settings/RunesSettings/SettingsSyncSettings.tsx
+++ b/src/screens/Settings/RunesSettings/SettingsSyncSettings.tsx
@@ -2,16 +2,11 @@ import {useCallback, useState} from 'react'
import {TextInput, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
+import * as persisted from '#/state/persisted'
import {
- useSettingsSyncStatus,
- usePullFromCloud,
- usePushToCloud,
-} from '#/features/settingsSync'
-import {
- useSettingsSyncEnabled,
useSetSettingsSyncEnabled,
+ useSettingsSyncEnabled,
} from '#/state/preferences'
-import * as persisted from '#/state/persisted'
import {useStorageManifestQuery} from '#/state/queries/storage-manifest'
import {useSession} from '#/state/session'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
@@ -23,6 +18,11 @@ import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as CloudSyncIcon} from '#/c
import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
+import {
+ usePullFromCloud,
+ usePushToCloud,
+ useSettingsSyncStatus,
+} from '#/features/settingsSync'
import {RunesScreenLayout} from './components/RunesScreenLayout'
function formatStatusLine(
@@ -201,6 +201,8 @@ export function RunesSettingsSyncSettingsScreen() {
) : (
(
export function Provider({children}: PropsWithChildren<{}>) {
const [state, setState] = useState(persisted.get('pdsLabel'))
- const setStateWrapped = useMemo(
- () => (pdsLabel: persisted.Schema['pdsLabel']) => {
+ const setStateWrapped = useCallback(
+ (pdsLabel: persisted.Schema['pdsLabel']) => {
setState(pdsLabel)
persisted.write('pdsLabel', pdsLabel)
},
diff --git a/src/state/queries/ai-preferences/index.ts b/src/state/queries/ai-preferences/index.ts
index 6ac8a61bf..d8106b14a 100644
--- a/src/state/queries/ai-preferences/index.ts
+++ b/src/state/queries/ai-preferences/index.ts
@@ -5,17 +5,17 @@ import {STALE} from '#/state/queries'
import {useAgent, useSession} from '#/state/session'
import {
AI_PREFERENCE_NSID,
+ type AIPreferenceRecord,
buildGlobalRecord,
type Patch,
- type AIPreferenceRecord,
} from './serde'
export {
- preferenceSetToTriStates,
AI_PREFERENCE_CATEGORIES,
AI_PREFERENCE_NSID,
type AIPreferenceCategory,
type AIPreferenceRecord,
+ preferenceSetToTriStates,
type TriState,
} from './serde'
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index fa0a6a43a..604e561f3 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -817,7 +817,8 @@ export const ComposePost = ({
}
}, [onPressCancel, closeAllDialogs, closeAllModals])
- const missingAltError = (() => {
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization -- restored memoization
+ const missingAltError = useMemo(() => {
if (!requireAltTextEnabled) {
return
}
@@ -839,7 +840,7 @@ export const ComposePost = ({
}
}
}
- })()
+ }, [thread, requireAltTextEnabled, l])
const canPost =
!missingAltError &&
diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx
index acc7f998a..f7e40300d 100644
--- a/src/view/com/pager/Pager.web.tsx
+++ b/src/view/com/pager/Pager.web.tsx
@@ -1,4 +1,11 @@
-import {Children, type JSX, useImperativeHandle, useRef, useState} from 'react'
+import {
+ Children,
+ type JSX,
+ useCallback,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from 'react'
import {View} from 'react-native'
import {flushSync} from 'react-dom'
@@ -40,35 +47,40 @@ export function Pager({
},
}))
- const onTabBarSelect = (index: number) => {
- const scrollY = window.scrollY
- // We want to determine if the tabbar is already "sticking" at the top (in which
- // case we should preserve and restore scroll), or if it is somewhere below in the
- // viewport (in which case a scroll jump would be jarring). We determine this by
- // measuring where the "anchor" element is (which we place just above the tabbar).
- let anchorTop = anchorRef.current
- ? (anchorRef.current as Element).getBoundingClientRect().top
- : -scrollY // If there's no anchor, treat the top of the page as one.
- const isSticking = anchorTop <= 5 // This would be 0 if browser scrollTo() was reliable.
+ /* eslint-disable react-hooks/preserve-manual-memoization -- restored memoization */
+ const onTabBarSelect = useCallback(
+ (index: number) => {
+ const scrollY = window.scrollY
+ // We want to determine if the tabbar is already "sticking" at the top (in which
+ // case we should preserve and restore scroll), or if it is somewhere below in the
+ // viewport (in which case a scroll jump would be jarring). We determine this by
+ // measuring where the "anchor" element is (which we place just above the tabbar).
+ let anchorTop = anchorRef.current
+ ? (anchorRef.current as Element).getBoundingClientRect().top
+ : -scrollY // If there's no anchor, treat the top of the page as one.
+ const isSticking = anchorTop <= 5 // This would be 0 if browser scrollTo() was reliable.
- if (isSticking) {
- scrollYs.current[selectedPage] = window.scrollY
- } else {
- scrollYs.current[selectedPage] = null
- }
- flushSync(() => {
- setSelectedPage(index)
- onPageSelected?.(index)
- })
- if (isSticking) {
- const restoredScrollY = scrollYs.current[index]
- if (restoredScrollY != null) {
- window.scrollTo(0, restoredScrollY)
+ if (isSticking) {
+ scrollYs.current[selectedPage] = window.scrollY
} else {
- window.scrollTo(0, scrollY + anchorTop)
+ scrollYs.current[selectedPage] = null
}
- }
- }
+ flushSync(() => {
+ setSelectedPage(index)
+ onPageSelected?.(index)
+ })
+ if (isSticking) {
+ const restoredScrollY = scrollYs.current[index]
+ if (restoredScrollY != null) {
+ window.scrollTo(0, restoredScrollY)
+ } else {
+ window.scrollTo(0, scrollY + anchorTop)
+ }
+ }
+ },
+ [selectedPage, setSelectedPage, onPageSelected],
+ )
+ /* eslint-enable react-hooks/preserve-manual-memoization */
return (
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index 2b3c2a36b..c3b2e1bf9 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -22,6 +22,7 @@ import {type CommonNavigatorParams} from '#/lib/routes/types'
import {convertBskyAppUrlIfNeeded} from '#/lib/strings/url-helpers'
import {emitSoftReset} from '#/state/events'
import {useModalControls} from '#/state/modals'
+import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useProfileQuery} from '#/state/queries/profile'
@@ -29,7 +30,6 @@ import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useCloseAllActiveElements} from '#/state/util'
-import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
import {Link} from '#/view/com/util/Link'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {Logo} from '#/view/icons/Logo'