survive session-bundle rebuild in chat, feeds, prefs, and age assurance
Review fixes (PR #11182 round 2). Client identity is no longer stable across token refresh - on web, a cross-tab sync rebuilds the bundle and disposes the old clients (whose fetch now throws) - so anything that captured a client at construction time must re-point at the live one: - MessagesEventBus and Convo get updateClient(); providers sync it via effect. No remount, so poll cursors and optimistic pendingMessages survive. - FeedAPI implementations get setClient(); the post-feed queryFn and pollLatest re-point page-held apis before fetching (Merge/Home apis are stateful across pages, so they cannot be rebuilt per fetch). - usePreferencesQuery applies fetched labeler dids to the live appview client (applyLabelersToClient, factored from session/moderation), restoring the old BskyAgent.getPreferences header side effect. - ageAssurance redirect overlay/dialog polling is mount-only with render-synced refs; a client swap mid-poll no longer latches the unmounted flag and strands the overlay. - Convo message-failure classification defers to lex's shouldRetry() instead of treating all status-less errors as recoverable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -178,18 +178,28 @@ function Inner() {
|
||||
const {_} = useLingui()
|
||||
const {hasSession} = useSession()
|
||||
const appviewClient = useAppviewClient()
|
||||
const polling = useRef(false)
|
||||
const unmounted = useRef(false)
|
||||
/*
|
||||
* The poll effect is mount-only so a session-bundle rebuild (web cross-tab
|
||||
* token sync, which swaps the appview client identity) does not restart the
|
||||
* flow or permanently latch it. Read the volatile values through refs kept
|
||||
* fresh each render so the next retry attempt picks up the current client.
|
||||
*/
|
||||
const clientRef = useRef(appviewClient)
|
||||
clientRef.current = appviewClient
|
||||
const hasSessionRef = useRef(hasSession)
|
||||
hasSessionRef.current = hasSession
|
||||
const openMetricFired = useRef(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const {close} = useRedirectOverlayContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (polling.current) return
|
||||
let cancelled = false
|
||||
|
||||
polling.current = true
|
||||
|
||||
ax.metric('ageAssurance:redirectDialogOpen', {})
|
||||
if (!openMetricFired.current) {
|
||||
openMetricFired.current = true
|
||||
ax.metric('ageAssurance:redirectDialogOpen', {})
|
||||
}
|
||||
|
||||
wait(
|
||||
3e3,
|
||||
@@ -197,10 +207,12 @@ function Inner() {
|
||||
5,
|
||||
() => true,
|
||||
async () => {
|
||||
if (!hasSession) return
|
||||
if (unmounted.current) return
|
||||
if (!hasSessionRef.current) return
|
||||
if (cancelled) return
|
||||
|
||||
const data = await refetchAgeAssuranceServerState({appviewClient})
|
||||
const data = await refetchAgeAssuranceServerState({
|
||||
appviewClient: clientRef.current,
|
||||
})
|
||||
|
||||
if (data?.state.status !== 'assured') {
|
||||
throw new Error(
|
||||
@@ -215,23 +227,24 @@ function Inner() {
|
||||
)
|
||||
.then(async data => {
|
||||
if (!data) return
|
||||
if (!hasSession) return
|
||||
if (unmounted.current) return
|
||||
if (!hasSessionRef.current) return
|
||||
if (cancelled) return
|
||||
|
||||
setSuccess(true)
|
||||
|
||||
ax.metric('ageAssurance:redirectDialogSuccess', {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (unmounted.current) return
|
||||
if (cancelled) return
|
||||
setError(true)
|
||||
ax.metric('ageAssurance:redirectDialogFail', {})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unmounted.current = true
|
||||
cancelled = true
|
||||
}
|
||||
}, [ax, hasSession, appviewClient])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
|
||||
@@ -86,18 +86,28 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
||||
const {_} = useLingui()
|
||||
const {hasSession} = useSession()
|
||||
const appviewClient = useAppviewClient()
|
||||
const polling = useRef(false)
|
||||
const unmounted = useRef(false)
|
||||
const control = useAgeAssuranceRedirectDialogControl()
|
||||
/*
|
||||
* The poll effect is mount-only so a session-bundle rebuild (web cross-tab
|
||||
* token sync, which swaps the appview client identity) does not restart the
|
||||
* flow or permanently latch it. Read the volatile values through refs kept
|
||||
* fresh each render so the next retry attempt picks up the current client.
|
||||
*/
|
||||
const clientRef = useRef(appviewClient)
|
||||
clientRef.current = appviewClient
|
||||
const hasSessionRef = useRef(hasSession)
|
||||
hasSessionRef.current = hasSession
|
||||
const openMetricFired = useRef(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (polling.current) return
|
||||
let cancelled = false
|
||||
|
||||
polling.current = true
|
||||
|
||||
ax.metric('ageAssurance:redirectDialogOpen', {})
|
||||
if (!openMetricFired.current) {
|
||||
openMetricFired.current = true
|
||||
ax.metric('ageAssurance:redirectDialogOpen', {})
|
||||
}
|
||||
|
||||
wait(
|
||||
3e3,
|
||||
@@ -105,10 +115,12 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
||||
5,
|
||||
() => true,
|
||||
async () => {
|
||||
if (!hasSession) return
|
||||
if (unmounted.current) return
|
||||
if (!hasSessionRef.current) return
|
||||
if (cancelled) return
|
||||
|
||||
const data = await refetchAgeAssuranceServerState({appviewClient})
|
||||
const data = await refetchAgeAssuranceServerState({
|
||||
appviewClient: clientRef.current,
|
||||
})
|
||||
|
||||
if (data?.state.status !== 'assured') {
|
||||
throw new Error(
|
||||
@@ -123,23 +135,24 @@ export function Inner({}: {optimisticState?: AgeAssuranceRedirectDialogState}) {
|
||||
)
|
||||
.then(async data => {
|
||||
if (!data) return
|
||||
if (!hasSession) return
|
||||
if (unmounted.current) return
|
||||
if (!hasSessionRef.current) return
|
||||
if (cancelled) return
|
||||
|
||||
setSuccess(true)
|
||||
|
||||
ax.metric('ageAssurance:redirectDialogSuccess', {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (unmounted.current) return
|
||||
if (cancelled) return
|
||||
setError(true)
|
||||
ax.metric('ageAssurance:redirectDialogFail', {})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unmounted.current = true
|
||||
cancelled = true
|
||||
}
|
||||
}, [ax, hasSession, appviewClient, control])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,10 @@ export class AuthorFeedAPI implements FeedAPI {
|
||||
this._params = feedParams
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
get params() {
|
||||
const params = {...this._params}
|
||||
params.includePins = params.filter === 'posts_and_author_threads'
|
||||
|
||||
@@ -37,6 +37,10 @@ export class CustomFeedAPI implements FeedAPI {
|
||||
this.userInterests = userInterests
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<app.bsky.feed.defs.FeedViewPost> {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const res = await this.client.call(
|
||||
|
||||
@@ -11,6 +11,10 @@ export class DemoFeedAPI implements FeedAPI {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async peekLatest(): Promise<app.bsky.feed.defs.FeedViewPost> {
|
||||
return DEMO_FEED.feed[0]
|
||||
|
||||
@@ -10,6 +10,10 @@ export class FollowingFeedAPI implements FeedAPI {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<app.bsky.feed.defs.FeedViewPost> {
|
||||
const res = await this.client.call(app.bsky.feed.getTimeline, {
|
||||
limit: 1,
|
||||
|
||||
@@ -56,6 +56,12 @@ export class HomeFeedAPI implements FeedAPI {
|
||||
this.userInterests = userInterests
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
this.following.setClient(client)
|
||||
this.discover.setClient(client)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new FollowingFeedAPI({client: this.client})
|
||||
this.discover = new CustomFeedAPI({
|
||||
|
||||
@@ -18,6 +18,10 @@ export class LikesFeedAPI implements FeedAPI {
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<app.bsky.feed.defs.FeedViewPost> {
|
||||
const res = await this.client.call(app.bsky.feed.getActorLikes, {
|
||||
...this.params,
|
||||
|
||||
@@ -18,6 +18,10 @@ export class ListFeedAPI implements FeedAPI {
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<app.bsky.feed.defs.FeedViewPost> {
|
||||
const res = await this.client.call(app.bsky.feed.getListFeed, {
|
||||
...this.params,
|
||||
|
||||
@@ -64,6 +64,14 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
})
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
this.following.setClient(client)
|
||||
for (const feed of this.customFeeds) {
|
||||
feed.setClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new MergeFeedSource_Following({
|
||||
client: this.client,
|
||||
@@ -203,6 +211,10 @@ class MergeFeedSource {
|
||||
this.feedTuners = feedTuners
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
get numReady() {
|
||||
return this.queue.length
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ export class PostListFeedAPI implements FeedAPI {
|
||||
}
|
||||
}
|
||||
|
||||
setClient(client: Client) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<app.bsky.feed.defs.FeedViewPost> {
|
||||
if (this.peek) return this.peek
|
||||
throw new Error('Has not fetched yet')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {type Client} from '@atproto/lex-client'
|
||||
|
||||
import {type app} from '#/lexicons'
|
||||
|
||||
export interface FeedAPIResponse {
|
||||
@@ -6,6 +8,13 @@ export interface FeedAPIResponse {
|
||||
}
|
||||
|
||||
export interface FeedAPI {
|
||||
/**
|
||||
* Swap in a fresh client. Feed pages retain their FeedAPI across paginations,
|
||||
* so the client captured at construction goes stale after a session-bundle
|
||||
* rebuild (web cross-tab token sync). Callers re-point the api at the current
|
||||
* client before each fetch/peek so a disposed client is never used.
|
||||
*/
|
||||
setClient(client: Client): void
|
||||
peekLatest(): Promise<app.bsky.feed.defs.FeedViewPost>
|
||||
fetch({
|
||||
cursor,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ACTIVE_POLL_INTERVAL,
|
||||
BACKGROUND_POLL_INTERVAL,
|
||||
INACTIVE_TIMEOUT,
|
||||
NETWORK_FAILURE_STATUSES,
|
||||
} from '#/state/messages/convo/const'
|
||||
import {
|
||||
type ConvoDispatch,
|
||||
@@ -176,6 +175,7 @@ export class Convo {
|
||||
this.setupPlaceholderData(params.placeholderData)
|
||||
}
|
||||
|
||||
this.updateClient = this.updateClient.bind(this)
|
||||
this.setConvo = this.setConvo.bind(this)
|
||||
this.subscribe = this.subscribe.bind(this)
|
||||
this.getSnapshot = this.getSnapshot.bind(this)
|
||||
@@ -194,6 +194,18 @@ export class Convo {
|
||||
this.updateLockStatus = this.updateLockStatus.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap in a fresh chat client. On web, a cross-tab token sync rebuilds the
|
||||
* session bundle (new client identities, same DID) and disposes the old one,
|
||||
* whose fetch then throws. Every request reads `this.chatClient` per call, so
|
||||
* reassigning the field keeps the convo alive without a reset (which would
|
||||
* drop optimistic `pendingMessages`). Same-DID rebuild keeps `senderUserDid`
|
||||
* valid, so it is intentionally left untouched.
|
||||
*/
|
||||
updateClient(client: Client) {
|
||||
this.chatClient = client
|
||||
}
|
||||
|
||||
private commit() {
|
||||
this.snapshot = undefined
|
||||
this.subscribers.forEach(subscriber => subscriber())
|
||||
@@ -1198,14 +1210,13 @@ export class Convo {
|
||||
const status = getErrorStatus(e)
|
||||
if (isXrpcError(e)) {
|
||||
/*
|
||||
* A status-less xrpc error is a network/transport failure (lex throws
|
||||
* `XrpcInternalError`, which carries no HTTP status). The old bridge
|
||||
* represented the same case with a sentinel `status` of `1`, which is a
|
||||
* member of `NETWORK_FAILURE_STATUSES` - so a network failure was
|
||||
* `recoverable`. Preserve that by treating `undefined` status the same
|
||||
* as a network-failure status here.
|
||||
* Defer to lex's own retry classification: transient statuses
|
||||
* (408/425/429/5xx) and transport/fetch failures are recoverable, while
|
||||
* permanent statuses and internal errors are not. This is the lex-native
|
||||
* analogue of the old `NETWORK_FAILURE_STATUSES` check, and correctly
|
||||
* excludes status-less internal/validation errors that are not retryable.
|
||||
*/
|
||||
if (status === undefined || NETWORK_FAILURE_STATUSES.includes(status)) {
|
||||
if (e.shouldRetry()) {
|
||||
this.pendingMessageFailure = 'recoverable'
|
||||
} else {
|
||||
this.pendingMessageFailure = 'unrecoverable'
|
||||
|
||||
@@ -2,7 +2,3 @@ export const ACTIVE_POLL_INTERVAL = 4e3
|
||||
export const MESSAGE_SCREEN_POLL_INTERVAL = 30e3
|
||||
export const BACKGROUND_POLL_INTERVAL = 60e3
|
||||
export const INACTIVE_TIMEOUT = 60e3 * 5
|
||||
|
||||
export const NETWORK_FAILURE_STATUSES = [
|
||||
1, 408, 425, 429, 500, 502, 503, 504, 522, 524,
|
||||
]
|
||||
|
||||
@@ -101,6 +101,10 @@ export function ConvoProvider({
|
||||
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
|
||||
useEffect(() => {
|
||||
convo.updateClient(chatClient)
|
||||
}, [convo, chatClient])
|
||||
|
||||
const appState = useAppState()
|
||||
const isActive = appState === 'active'
|
||||
useFocusEffect(
|
||||
|
||||
@@ -42,6 +42,17 @@ export class MessagesEventBus {
|
||||
this.init()
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap in a fresh chat client. On web, a cross-tab token sync rebuilds the
|
||||
* session bundle (new client identities, same DID) and disposes the old one,
|
||||
* whose fetch then throws. Every request reads `this.chatClient` per call, so
|
||||
* reassigning the field is enough to keep polling alive without tearing down
|
||||
* the bus and its in-memory poll cursor.
|
||||
*/
|
||||
updateClient(client: Client) {
|
||||
this.chatClient = client
|
||||
}
|
||||
|
||||
requestPollInterval(interval: number) {
|
||||
const id = nanoid()
|
||||
this.requestedPollIntervals.set(id, interval)
|
||||
|
||||
@@ -50,6 +50,10 @@ export function MessagesEventBusProviderInner({
|
||||
}),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
bus.updateClient(chatClient)
|
||||
}, [bus, chatClient])
|
||||
|
||||
useEffect(() => {
|
||||
bus.resume()
|
||||
|
||||
|
||||
@@ -201,6 +201,15 @@ export function usePostFeedQuery(
|
||||
cursor: undefined,
|
||||
}
|
||||
|
||||
/*
|
||||
* A page-held api captured its client at construction. On web, a
|
||||
* cross-tab token sync rebuilds the session bundle and disposes the old
|
||||
* client (whose fetch then throws), so re-point the api at the current
|
||||
* client before fetching. Safe because the swap is same-DID (fresh
|
||||
* tokens only), so the stateful Merge/Home api's pagination is unaffected.
|
||||
*/
|
||||
api.setClient(client)
|
||||
|
||||
const res = await api.fetch({cursor, limit: fetchLimit})
|
||||
|
||||
/*
|
||||
@@ -422,7 +431,7 @@ export function usePostFeedQuery(
|
||||
return query
|
||||
}
|
||||
|
||||
export async function pollLatest(page: FeedPage | undefined) {
|
||||
export async function pollLatest(page: FeedPage | undefined, client: Client) {
|
||||
if (!page) {
|
||||
return false
|
||||
}
|
||||
@@ -431,6 +440,9 @@ export async function pollLatest(page: FeedPage | undefined) {
|
||||
}
|
||||
|
||||
logger.debug('usePostFeedQuery: pollLatest')
|
||||
// The page-held api may carry a disposed client after a session-bundle
|
||||
// rebuild - re-point it at the current client before peeking.
|
||||
page.api.setClient(client)
|
||||
const post = await page.api.peekLatest()
|
||||
if (post) {
|
||||
const slices = page.tuner.tune([post], {
|
||||
|
||||
@@ -38,8 +38,9 @@ import {
|
||||
type UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences/types'
|
||||
import {createQueryKey} from '#/state/queries/util'
|
||||
import {usePdsClient} from '#/state/session'
|
||||
import {useAppviewClient, usePdsClient} from '#/state/session'
|
||||
import {saveLabelers} from '#/state/session/agent-config'
|
||||
import {applyLabelersToClient} from '#/state/session/moderation'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -57,6 +58,7 @@ export const preferencesQueryKey = createQueryKey(
|
||||
|
||||
export function usePreferencesQuery() {
|
||||
const client = usePdsClient()
|
||||
const appviewClient = useAppviewClient()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
const query = useQuery({
|
||||
@@ -71,11 +73,19 @@ export function usePreferencesQuery() {
|
||||
} else {
|
||||
const res = await client.call(getPreferences)
|
||||
|
||||
const labelerDids = res.moderationPrefs.labelers.map(l => l.did)
|
||||
|
||||
// save to local storage to ensure there are labels on initial requests
|
||||
void saveLabelers(
|
||||
client.did,
|
||||
res.moderationPrefs.labelers.map(l => l.did),
|
||||
)
|
||||
void saveLabelers(client.did, labelerDids)
|
||||
|
||||
/*
|
||||
* Sync the subscribed labelers to the live appview client, mirroring the
|
||||
* old `BskyAgent.getPreferences` side effect (which called
|
||||
* `configureLabelersHeader`). Without this, subscribing/unsubscribing to
|
||||
* a labeler would not affect server-attached labels until the session
|
||||
* bundle is rebuilt.
|
||||
*/
|
||||
applyLabelersToClient(appviewClient, labelerDids)
|
||||
|
||||
const preferences: UsePreferencesQueryResponse = {
|
||||
...res,
|
||||
|
||||
@@ -24,6 +24,27 @@ function configureGlobalAppLabelers(dids: string[]) {
|
||||
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an account's subscribed labeler DIDs to a live appview client. The lex
|
||||
* `Client` rebuilds the `atproto-accept-labelers` header per request, so this
|
||||
* takes effect on the very next request without a client rebuild.
|
||||
*
|
||||
* The Bluesky moderation labeler is always re-asserted as the base: sending ANY
|
||||
* `atproto-accept-labelers` header replaces the server-side default, and
|
||||
* `setLabelers` clears then re-adds, so the moderation DID must be included
|
||||
* explicitly to stay active.
|
||||
*/
|
||||
export function applyLabelersToClient(
|
||||
client: Client,
|
||||
subscribedDids: string[],
|
||||
) {
|
||||
const perAccount = subscribedDids.filter(did => did !== api.moderation.did)
|
||||
client.setLabelers([
|
||||
api.moderation.did,
|
||||
...perAccount,
|
||||
] as `did:${string}:${string}`[])
|
||||
}
|
||||
|
||||
export function configureModerationForGuest() {
|
||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||
// Don't add any other global behavior here!
|
||||
@@ -51,18 +72,8 @@ export async function configureModerationForAccount(
|
||||
// The code below is actually relevant to production (and isn't global).
|
||||
const labelerDids = await readLabelers(account.did).catch(_ => {})
|
||||
if (labelerDids) {
|
||||
const perAccount = labelerDids.filter(did => did !== api.moderation.did)
|
||||
/*
|
||||
* Apply the per-account labelers to the appview client. It re-asserts the
|
||||
* Bluesky moderation labeler as its base because sending ANY
|
||||
* `atproto-accept-labelers` header replaces the server-side default -
|
||||
* `setLabelers` clears then re-adds, so the moderation DID must be included
|
||||
* explicitly to stay active.
|
||||
*/
|
||||
bundle.appviewClient.setLabelers([
|
||||
api.moderation.did,
|
||||
...perAccount,
|
||||
] as `did:${string}:${string}`[])
|
||||
// Apply the per-account labelers to the appview client.
|
||||
applyLabelersToClient(bundle.appviewClient, labelerDids)
|
||||
} else {
|
||||
// If there are no headers in the storage, we'll not send them on the initial requests.
|
||||
// If we wanted to fix this, we could block on the preferences query here.
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
usePostFeedQuery,
|
||||
} from '#/state/queries/post-feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useAppviewClient, useSession} from '#/state/session'
|
||||
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||
import {useSelectedFeed} from '#/state/shell/selected-feed'
|
||||
import {List, type ListRef} from '#/view/com/util/List'
|
||||
@@ -260,6 +260,7 @@ let PostFeed = ({
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const client = useAppviewClient()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const feedFeedback = useFeedFeedbackContext()
|
||||
@@ -333,7 +334,7 @@ let PostFeed = ({
|
||||
}
|
||||
|
||||
try {
|
||||
if (await pollLatest(data.pages[0])) {
|
||||
if (await pollLatest(data.pages[0], client)) {
|
||||
if (isEmpty) {
|
||||
void refetch()
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user