diff --git a/src/components/ProfileBadges.tsx b/src/components/ProfileBadges.tsx index d121ca86f..950d2684d 100644 --- a/src/components/ProfileBadges.tsx +++ b/src/components/ProfileBadges.tsx @@ -5,13 +5,13 @@ import {useLingui} from '@lingui/react/macro' import {HITSLOP_20} from '#/lib/constants' import {useProfileShadow} from '#/state/cache/profile-shadow' import {type Shadow} from '#/state/cache/types' -import {useIsPdsProfileViewable} from '#/state/pds-viewability' +import {usePdsProfilePriority} from '#/state/pds-viewability' import { usePdsLabelEnabled, usePdsLabelHideBskyPds, } from '#/state/preferences/pds-label' import {useDeerVerificationProfileOverlay} from '#/state/queries/deer-verification' -import {usePdsFaviconQuery, usePdsLabelQuery} from '#/state/queries/pds-label' +import {usePdsFaviconUrl, usePdsLabelQuery} from '#/state/queries/pds-label' import {atoms as a, useAlf, type ViewStyleProp} from '#/alf' import {useNativeFontScale} from '#/alf/util/dimensions' import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge' @@ -98,12 +98,14 @@ export function ProfileBadgesFromProfileShadow({ !!shadowed.handle && shadowed.handle.endsWith('.bsky.social') const shouldShowPdsCandidate = pdsLabelEnabled && !(hideBskyPds && isBskyHandle) - const isPdsProfileViewable = useIsPdsProfileViewable(shadowed.did) - const shouldResolvePds = shouldShowPdsCandidate && isPdsProfileViewable + const pdsProfilePriority = usePdsProfilePriority(shadowed.did) + const shouldResolvePds = + shouldShowPdsCandidate && pdsProfilePriority !== 'off' const {data: pdsData, isLoading: isPdsLoading} = usePdsLabelQuery( shouldResolvePds ? shadowed.did : undefined, + pdsProfilePriority, ) - const {data: pdsFaviconUrl} = usePdsFaviconQuery( + const pdsFaviconUrl = usePdsFaviconUrl( pdsData && !pdsData.isBsky && !pdsData.isBridged ? pdsData.pdsUrl : undefined, diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx index d891d7411..1e97918f2 100644 --- a/src/lib/react-query.tsx +++ b/src/lib/react-query.tsx @@ -138,7 +138,11 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd { shouldDehydrateMutation: (_: any) => false, shouldDehydrateQuery: query => { - return isQueryPersisted(query.queryKey) + // PDS labels moved to a bounded, interaction-aware cache. Excluding the + // old keys removes them from the shared persisted query cache on save. + return ( + query.queryKey[0] !== 'pds-label' && isQueryPersisted(query.queryKey) + ) }, } diff --git a/src/state/__tests__/pds-viewability.test.ts b/src/state/__tests__/pds-viewability.test.ts new file mode 100644 index 000000000..a881e67a5 --- /dev/null +++ b/src/state/__tests__/pds-viewability.test.ts @@ -0,0 +1,42 @@ +import {describe, expect, it, jest} from '@jest/globals' + +import {createPdsViewabilityStore} from '#/state/pds-viewability' + +describe(`PDS profile priority`, () => { + it(`promotes a profile from off to near to visible without downgrading`, () => { + const store = createPdsViewabilityStore() + const listener = jest.fn() + store.subscribe(`did:plc:alice`, listener) + + expect(store.getPriority(`did:plc:alice`)).toBe(`off`) + + store.markNearViewport([`did:plc:alice`]) + expect(store.getPriority(`did:plc:alice`)).toBe(`near`) + expect(listener).toHaveBeenCalledTimes(1) + + store.markNearViewport([`did:plc:alice`]) + expect(listener).toHaveBeenCalledTimes(1) + + store.markVisible([`did:plc:alice`]) + expect(store.getPriority(`did:plc:alice`)).toBe(`visible`) + expect(listener).toHaveBeenCalledTimes(2) + + store.markNearViewport([`did:plc:alice`]) + expect(store.getPriority(`did:plc:alice`)).toBe(`visible`) + expect(listener).toHaveBeenCalledTimes(2) + }) + + it(`updates only subscribers for the promoted DID`, () => { + const store = createPdsViewabilityStore() + const aliceListener = jest.fn() + const bobListener = jest.fn() + const unsubscribeAlice = store.subscribe(`did:plc:alice`, aliceListener) + store.subscribe(`did:plc:bob`, bobListener) + + unsubscribeAlice() + store.markVisible([`did:plc:alice`, `did:plc:bob`]) + + expect(aliceListener).not.toHaveBeenCalled() + expect(bobListener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/state/pds-viewability.tsx b/src/state/pds-viewability.tsx index ad27af486..831ace8f1 100644 --- a/src/state/pds-viewability.tsx +++ b/src/state/pds-viewability.tsx @@ -9,23 +9,35 @@ import { type Listener = () => void export type PdsViewabilityStore = ReturnType +export type PdsProfilePriority = 'off' | 'near' | 'visible' export function createPdsViewabilityStore() { - const viewableDids = new Set() + const prioritiesByDid = new Map>() const listenersByDid = new Map>() - return { - markViewable(dids: Iterable) { - for (const did of dids) { - if (viewableDids.has(did)) continue + function mark( + dids: Iterable, + priority: Exclude, + ) { + for (const did of dids) { + const current = prioritiesByDid.get(did) + if (current === 'visible' || current === priority) continue - viewableDids.add(did) - listenersByDid.get(did)?.forEach(listener => listener()) - listenersByDid.delete(did) - } + prioritiesByDid.set(did, priority) + listenersByDid.get(did)?.forEach(listener => listener()) + if (priority === 'visible') listenersByDid.delete(did) + } + } + + return { + markNearViewport(dids: Iterable) { + mark(dids, 'near') + }, + markVisible(dids: Iterable) { + mark(dids, 'visible') }, - isViewable(did: string) { - return viewableDids.has(did) + getPriority(did: string): PdsProfilePriority { + return prioritiesByDid.get(did) ?? 'off' }, subscribe(did: string, listener: Listener) { let listeners = listenersByDid.get(did) @@ -58,16 +70,16 @@ export function PdsViewabilityProvider({ /** * Profiles outside a virtualized feed resolve immediately. Inside a feed, a - * profile becomes eligible once its row has met the list's viewability rules. + * profile is queued speculatively near the viewport and promoted when visible. */ -export function useIsPdsProfileViewable(did: string) { +export function usePdsProfilePriority(did: string): PdsProfilePriority { const store = useContext(PdsViewabilityContext) const subscribe = useCallback( (listener: Listener) => store?.subscribe(did, listener) ?? (() => {}), [did, store], ) const getSnapshot = useCallback( - () => store?.isViewable(did) ?? true, + () => store?.getPriority(did) ?? 'visible', [did, store], ) diff --git a/src/state/queries/pds-label-resolver.ts b/src/state/queries/pds-label-resolver.ts new file mode 100644 index 000000000..70d5731f0 --- /dev/null +++ b/src/state/queries/pds-label-resolver.ts @@ -0,0 +1,389 @@ +import {InteractionManager} from 'react-native' + +import {isBridgedPdsUrl, isBskyPdsUrl} from '#/state/queries/pds-label.util' +import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' +import {IS_NATIVE} from '#/env' +import {create as createArchiveDB} from '#/storage/archive/db' + +export type PdsRequestPriority = 'near' | 'visible' + +export type PdsLabelData = { + pdsUrl: string + isBsky: boolean + isBridged: boolean +} + +type CacheEntry = { + savedAt: number + data: PdsLabelData +} + +type CacheBucket = Record + +type PendingTask = { + did: `did:${string}:${string}` + priority: PdsRequestPriority + order: number + promise: Promise + resolve: (data: PdsLabelData | undefined) => void + reject: (err: unknown) => void +} + +export type PdsLabelSnapshot = { + data: PdsLabelData | undefined + isLoading: boolean +} + +type LabelState = { + snapshot: PdsLabelSnapshot + resolvedAt: number + lastAccessed: number + promise: Promise | undefined +} + +const CACHE_TTL = 60 * 60 * 1000 +const CACHE_BUCKET_COUNT = 64 +const CACHE_BUCKET_LIMIT = 64 +const PERSIST_BATCH_SIZE = 12 +const PERSIST_DEBOUNCE = 2000 +const MAX_ACTIVE = 3 +const MAX_ACTIVE_NEAR = 2 +const MAX_LABEL_STATES = CACHE_BUCKET_COUNT * CACHE_BUCKET_LIMIT + +const EMPTY_SNAPSHOT: PdsLabelSnapshot = { + data: undefined, + isLoading: false, +} + +const cacheStore = createArchiveDB({id: 'witchsky-pds-label-cache-v1'}) +const loadedBuckets = new Map>() +const pendingWrites = new Map() +const pendingTasks = new Map() +const labelStates = new Map() +const labelListeners = new Map void>>() + +let taskOrder = 0 +let activeCount = 0 +let activeNearCount = 0 +let nearDrainHandle: + | ReturnType + | undefined +let persistTimer: ReturnType | undefined +let persistInteractionHandle: + | ReturnType + | undefined + +function notifyLabelListeners(did: string) { + labelListeners.get(did)?.forEach(listener => listener()) +} + +function pruneLabelStates() { + if (labelStates.size <= MAX_LABEL_STATES) return + + const removable = Array.from(labelStates.entries()) + .filter(([did, state]) => !state.promise && !labelListeners.has(did)) + .sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed) + for (const [did] of removable) { + labelStates.delete(did) + if (labelStates.size <= MAX_LABEL_STATES) return + } +} + +function bucketIndex(did: string) { + let hash = 2166136261 + for (let i = 0; i < did.length; i++) { + hash ^= did.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0) % CACHE_BUCKET_COUNT +} + +function bucketKey(index: number) { + return `bucket-${index}` +} + +function isCacheEntry(value: unknown): value is CacheEntry { + if (!value || typeof value !== 'object') return false + const entry = value as Partial + return ( + typeof entry.savedAt === 'number' && + !!entry.data && + typeof entry.data.pdsUrl === 'string' && + typeof entry.data.isBsky === 'boolean' && + typeof entry.data.isBridged === 'boolean' + ) +} + +function loadBucket(index: number): Promise { + let promise = loadedBuckets.get(index) + if (promise) return promise + + promise = Promise.resolve(cacheStore.get(bucketKey(index))) + .then(raw => { + if (!raw) return {} + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return {} + + const now = Date.now() + const bucket: CacheBucket = {} + for (const [did, entry] of Object.entries(parsed)) { + if (isCacheEntry(entry) && now - entry.savedAt < CACHE_TTL) { + bucket[did] = entry + } + } + return bucket + } catch { + return {} + } + }) + .catch(() => ({})) + loadedBuckets.set(index, promise) + return promise +} + +async function readCached(did: string) { + const bucket = await loadBucket(bucketIndex(did)) + const entry = bucket[did] + if (!entry || Date.now() - entry.savedAt >= CACHE_TTL) return undefined + return entry.data +} + +function queuePersist(did: string, data: PdsLabelData) { + const entry = {savedAt: Date.now(), data} + pendingWrites.set(did, entry) + void loadBucket(bucketIndex(did)).then(bucket => { + bucket[did] = entry + }) + if (persistTimer || persistInteractionHandle) return + + persistTimer = setTimeout(() => { + persistTimer = undefined + if (IS_NATIVE) { + persistInteractionHandle = InteractionManager.runAfterInteractions(() => { + persistInteractionHandle = undefined + void flushPersistBatch().catch(() => {}) + }) + } else { + void flushPersistBatch().catch(() => {}) + } + }, PERSIST_DEBOUNCE) +} + +async function flushPersistBatch() { + const batch = Array.from(pendingWrites.entries()).slice(0, PERSIST_BATCH_SIZE) + for (const [did] of batch) pendingWrites.delete(did) + + const byBucket = new Map>() + for (const [did, entry] of batch) { + const index = bucketIndex(did) + const entries = byBucket.get(index) ?? [] + entries.push([did, entry]) + byBucket.set(index, entries) + } + + await Promise.all( + Array.from(byBucket.entries()).map(async ([index, entries]) => { + const bucket = await loadBucket(index) + for (const [did, entry] of entries) bucket[did] = entry + + const boundedEntries = Object.entries(bucket) + .sort(([, a], [, b]) => b.savedAt - a.savedAt) + .slice(0, CACHE_BUCKET_LIMIT) + const boundedBucket = Object.fromEntries(boundedEntries) + loadedBuckets.set(index, Promise.resolve(boundedBucket)) + await cacheStore.set(bucketKey(index), JSON.stringify(boundedBucket)) + }), + ) + + if (pendingWrites.size > 0) { + persistTimer = setTimeout(() => { + persistTimer = undefined + if (IS_NATIVE) { + persistInteractionHandle = InteractionManager.runAfterInteractions( + () => { + persistInteractionHandle = undefined + void flushPersistBatch().catch(() => {}) + }, + ) + } else { + void flushPersistBatch().catch(() => {}) + } + }, PERSIST_DEBOUNCE) + } +} + +function nextTask(allowNear: boolean) { + let next: PendingTask | undefined + for (const task of pendingTasks.values()) { + if (task.priority === 'near' && !allowNear) continue + if (task.priority === 'near' && activeNearCount >= MAX_ACTIVE_NEAR) continue + if ( + !next || + (task.priority === 'visible' && next.priority === 'near') || + (task.priority === next.priority && task.order < next.order) + ) { + next = task + } + } + return next +} + +function drainQueue(allowNear: boolean) { + while (activeCount < MAX_ACTIVE) { + const task = nextTask(allowNear) + if (!task) return + + pendingTasks.delete(task.did) + activeCount++ + if (task.priority === 'near') activeNearCount++ + + void resolvePdsServiceUrl(task.did) + .then(pdsUrl => { + if (!pdsUrl) return undefined + const data = { + pdsUrl, + isBsky: isBskyPdsUrl(pdsUrl), + isBridged: isBridgedPdsUrl(pdsUrl), + } + queuePersist(task.did, data) + return data + }) + .then(task.resolve, task.reject) + .finally(() => { + activeCount-- + if (task.priority === 'near') activeNearCount-- + if (IS_NATIVE) { + drainQueue(false) + if ( + Array.from(pendingTasks.values()).some(t => t.priority === 'near') + ) { + requestDrain('near') + } + } else { + drainQueue(true) + } + }) + } +} + +function requestDrain(priority: PdsRequestPriority) { + if (priority === 'visible' || !IS_NATIVE) { + drainQueue(!IS_NATIVE) + } else if (!nearDrainHandle) { + nearDrainHandle = InteractionManager.runAfterInteractions(() => { + nearDrainHandle = undefined + drainQueue(true) + }) + } +} + +function enqueueResolution( + did: `did:${string}:${string}`, + priority: PdsRequestPriority, +) { + const existing = pendingTasks.get(did) + if (existing) { + if (priority === 'visible' && existing.priority === 'near') { + existing.priority = 'visible' + requestDrain('visible') + } + return existing.promise + } + + let resolveTask!: (data: PdsLabelData | undefined) => void + let rejectTask!: (err: unknown) => void + const promise = new Promise((resolve, reject) => { + resolveTask = resolve + rejectTask = reject + }) + pendingTasks.set(did, { + did, + priority, + order: taskOrder++, + promise, + resolve: resolveTask, + reject: rejectTask, + }) + requestDrain(priority) + return promise +} + +export function promotePdsLabelResolution(did: string) { + const task = pendingTasks.get(did) + if (task?.priority === 'near') { + task.priority = 'visible' + requestDrain('visible') + } +} + +export function subscribePdsLabel(did: string, listener: () => void) { + let listeners = labelListeners.get(did) + if (!listeners) { + listeners = new Set() + labelListeners.set(did, listeners) + } + listeners.add(listener) + + return () => { + listeners.delete(listener) + if (listeners.size === 0) labelListeners.delete(did) + } +} + +export function getPdsLabelSnapshot(did: string) { + const state = labelStates.get(did) + if (!state) return EMPTY_SNAPSHOT + return state.snapshot +} + +export function requestPdsLabel( + did: `did:${string}:${string}`, + priority: PdsRequestPriority, +) { + let state = labelStates.get(did) + const now = Date.now() + if (state?.promise) { + if (priority === 'visible') promotePdsLabelResolution(did) + return + } + if (state?.resolvedAt && now - state.resolvedAt < CACHE_TTL) return + + if (!state) { + state = { + snapshot: EMPTY_SNAPSHOT, + resolvedAt: 0, + lastAccessed: now, + promise: undefined, + } + labelStates.set(did, state) + pruneLabelStates() + } + + state.lastAccessed = now + state.snapshot = {...state.snapshot, isLoading: true} + notifyLabelListeners(did) + + state.promise = resolvePdsLabel(did, priority) + .then(data => { + state.snapshot = {data, isLoading: false} + state.resolvedAt = Date.now() + }) + .catch(() => { + state.snapshot = {...state.snapshot, isLoading: false} + }) + .finally(() => { + state.promise = undefined + state.lastAccessed = Date.now() + notifyLabelListeners(did) + }) +} + +export async function resolvePdsLabel( + did: `did:${string}:${string}`, + priority: PdsRequestPriority, +) { + const cached = await readCached(did) + if (cached) return cached + return enqueueResolution(did, priority) +} diff --git a/src/state/queries/pds-label.ts b/src/state/queries/pds-label.ts index 742c47fa6..27dcb8d57 100644 --- a/src/state/queries/pds-label.ts +++ b/src/state/queries/pds-label.ts @@ -1,62 +1,52 @@ -import {useQuery} from '@tanstack/react-query' +import {useCallback, useEffect, useSyncExternalStore} from 'react' +import {type PdsProfilePriority} from '#/state/pds-viewability' import {useFaviconService} from '#/state/preferences/favicon-service' -import {GCTIME} from '#/state/queries' import { getFaviconServiceUrl, getPdsFallbackFaviconUrl, isBridgedPdsUrl, isBskyPdsUrl, } from '#/state/queries/pds-label.util' -import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity' -import {createQueryKey} from '#/state/queries/util' +import { + getPdsLabelSnapshot, + requestPdsLabel, + subscribePdsLabel, +} from '#/state/queries/pds-label-resolver' export {getPdsFallbackFaviconUrl, isBridgedPdsUrl, isBskyPdsUrl} -export const RQKEY_ROOT = 'pds-label' -export const RQKEY = (did: string) => - createQueryKey(RQKEY_ROOT, {did}, {persistedVersion: 1}) +export function usePdsLabelQuery( + did: string | undefined, + priority: PdsProfilePriority, +) { + const enabled = !!did && priority !== 'off' + const subscribe = useCallback( + (listener: () => void) => + did ? subscribePdsLabel(did, listener) : () => {}, + [did], + ) + const getSnapshot = useCallback( + () => (did ? getPdsLabelSnapshot(did) : getPdsLabelSnapshot('')), + [did], + ) + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) -export function usePdsLabelQuery(did: string | undefined) { - return useQuery({ - queryKey: RQKEY(did ?? ''), - queryFn: async () => { - if (!did) return null - const pdsUrl = await resolvePdsServiceUrl( + useEffect(() => { + if (enabled) { + requestPdsLabel( did as `did:${string}:${string}`, + priority === 'visible' ? 'visible' : 'near', ) - if (!pdsUrl) return undefined - const isBsky = isBskyPdsUrl(pdsUrl) - const isBridged = isBridgedPdsUrl(pdsUrl) - return {pdsUrl, isBsky, isBridged} - }, - enabled: !!did, - subscribed: !!did, - staleTime: 1000 * 60 * 60, // 1 hour - gcTime: GCTIME.INFINITY, - }) -} + } + }, [did, enabled, priority]) -export const RQKEY_FAVICON_ROOT = 'pds-favicon' -export const RQKEY_FAVICON = (pdsUrl: string, faviconService: string) => [ - RQKEY_FAVICON_ROOT, - pdsUrl, - faviconService, -] + return snapshot +} -export function usePdsFaviconQuery(pdsUrl: string | undefined) { +export function usePdsFaviconUrl(pdsUrl: string | undefined) { const faviconService = useFaviconService() - const isEnabled = Boolean(pdsUrl && faviconService) - const queryKey = isEnabled - ? RQKEY_FAVICON(pdsUrl!, faviconService!) - : ['pds-favicon-disabled'] - - return useQuery({ - queryKey, - queryFn: () => - isEnabled ? getFaviconServiceUrl(pdsUrl!, faviconService!) : undefined, - enabled: isEnabled, - subscribed: isEnabled, - staleTime: 1000 * 60 * 60, // 1 hour - }) + return pdsUrl && faviconService + ? getFaviconServiceUrl(pdsUrl, faviconService) + : undefined } diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 1ca286b92..cc38350d2 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -222,6 +222,24 @@ function collectPdsProfileDids( return dids } +function collectPdsProfileDidsForFeedRow(item: FeedRow) { + const dids = new Set() + if (item.type === 'sliceItem') { + collectPdsProfileDids(item.slice.items[item.indexInSlice].post, dids) + } else if (item.type === 'reposts') { + for (const slice of item.items) { + for (const sliceItem of slice.items) { + collectPdsProfileDids(sliceItem.post, dids) + } + } + } else if (item.type === 'videoGridRow') { + for (const sliceItem of item.items) { + collectPdsProfileDids(sliceItem.post, dids) + } + } + return dids +} + type FeedPostSliceOrGroup = | (FeedPostSlice & { isRepostSlice?: false @@ -1172,32 +1190,23 @@ let PostFeed = ({ const markPdsProfilesNearViewport = useCallback( (item: FeedRow) => { - if (item.type === 'sliceItem') { - pdsViewabilityStore.markViewable( - collectPdsProfileDids(item.slice.items[item.indexInSlice].post), - ) - } else if (item.type === 'reposts') { - pdsViewabilityStore.markViewable( - item.items.flatMap(slice => - slice.items.flatMap(sliceItem => - Array.from(collectPdsProfileDids(sliceItem.post)), - ), - ), - ) - } else if (item.type === 'videoGridRow') { - pdsViewabilityStore.markViewable( - item.items.flatMap(sliceItem => - Array.from(collectPdsProfileDids(sliceItem.post)), - ), - ) - } + pdsViewabilityStore.markNearViewport( + collectPdsProfileDidsForFeedRow(item), + ) + }, + [pdsViewabilityStore], + ) + + const markPdsProfilesVisible = useCallback( + (item: FeedRow) => { + pdsViewabilityStore.markVisible(collectPdsProfileDidsForFeedRow(item)) }, [pdsViewabilityStore], ) const onItemSeen = useCallback( (item: FeedRow) => { - markPdsProfilesNearViewport(item) + markPdsProfilesVisible(item) feedFeedback.onItemSeen(item) @@ -1310,7 +1319,7 @@ let PostFeed = ({ liveNowConfig, getPostPosition, ax, - markPdsProfilesNearViewport, + markPdsProfilesVisible, ], )