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'