|
|
@@ -2,12 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
import { appSetting } from 'app/lib/util'
|
|
|
import emitter, { EVENTS } from 'app/context/emitter'
|
|
|
+import { subscribe } from 'app/ui/atoms/socket'
|
|
|
import {
|
|
|
isSameItemsForUniList,
|
|
|
flattenPagesForUniList,
|
|
|
+ dedupeUniListItems,
|
|
|
fetchUniListData,
|
|
|
prependItemToUniListQueryCache,
|
|
|
removeItemFromUniListQueryCache,
|
|
|
+ mergeFirstPageIntoUniListQueryCache,
|
|
|
cursorNextPageParam,
|
|
|
} from './helpers'
|
|
|
|
|
|
@@ -28,10 +31,32 @@ export {
|
|
|
startPerPageNextPageParam,
|
|
|
prependItemToUniListQueryCache,
|
|
|
removeItemFromUniListQueryCache,
|
|
|
+ mergeFirstPageIntoUniListQueryCache,
|
|
|
+ dedupeUniListItems,
|
|
|
matchesFeedOwnerFilter,
|
|
|
isSameItemsForUniList,
|
|
|
} from './helpers'
|
|
|
|
|
|
+/** Batch window for timeline `added` events, plus a random spread so clients don't all hit UNA at once. */
|
|
|
+const TIMELINE_BATCH_MS = 1000
|
|
|
+const TIMELINE_JITTER_MS = 2000
|
|
|
+/** Cap on timeline ids remembered as deleted, so a first page fetched before a deletion cannot bring the row back. */
|
|
|
+const TIMELINE_DELETED_MAX = 200
|
|
|
+
|
|
|
+type TimelineSocketEvent = { id?: number | string }
|
|
|
+
|
|
|
+/** Timeline socket payload: a JSON string or an object (`{ id, author_id, peformer_id }`). */
|
|
|
+function parseTimelinePayload(payload: unknown): TimelineSocketEvent | null {
|
|
|
+ if (typeof payload === 'string') {
|
|
|
+ try {
|
|
|
+ return JSON.parse(payload)
|
|
|
+ } catch {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return payload && typeof payload === 'object' ? (payload as TimelineSocketEvent) : null
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Shared UniList infinite query: TanStack is the store.
|
|
|
* First page is always fetched (no page-JSON seed). Skeletons until that returns.
|
|
|
@@ -48,9 +73,10 @@ export function useUniListQuery({
|
|
|
getNextPageParam = cursorNextPageParam,
|
|
|
listenPageReload = false,
|
|
|
listenFeed = false,
|
|
|
+ listenTimeline = false,
|
|
|
matchesNewContent,
|
|
|
canFetchNext,
|
|
|
-}: { queryKey: any; requestUrl: string; defaultParams: any; enabled?: boolean; deferNewItems?: any; refetchOnWindowFocus?: any; refetchOnReconnect?: any; gcTime?: number; getNextPageParam?: any; listenPageReload?: boolean; listenFeed?: boolean; matchesNewContent?: any; canFetchNext?: any }) {
|
|
|
+}: { queryKey: any; requestUrl: string; defaultParams: any; enabled?: boolean; deferNewItems?: any; refetchOnWindowFocus?: any; refetchOnReconnect?: any; gcTime?: number; getNextPageParam?: any; listenPageReload?: boolean; listenFeed?: boolean; listenTimeline?: boolean; matchesNewContent?: any; canFetchNext?: any }) {
|
|
|
const queryClient = useQueryClient()
|
|
|
// Exposed as `skipToastRef` (tail skeleton count). Not read in render here:
|
|
|
// whether a data change is shown or deferred is decided by `requested` below.
|
|
|
@@ -110,7 +136,7 @@ export function useUniListQuery({
|
|
|
const isPending = (queryResult as { isPending?: boolean }).isPending
|
|
|
|
|
|
const cacheItems = useMemo(
|
|
|
- () => flattenPagesForUniList(pagesData),
|
|
|
+ () => dedupeUniListItems(flattenPagesForUniList(pagesData)),
|
|
|
[pagesData]
|
|
|
)
|
|
|
|
|
|
@@ -157,6 +183,75 @@ export function useUniListQuery({
|
|
|
return () => subscription.remove()
|
|
|
}, [listenFeed, queryClient])
|
|
|
|
|
|
+ /*
|
|
|
+ * Timeline socket (`bx_timeline_0`): every client gets every post of the
|
|
|
+ * site, so a full refetch here (all loaded pages, on every client at once)
|
|
|
+ * loaded UNA on each post. A deleted row is dropped from the cache without
|
|
|
+ * a request. Added posts are batched: one first-page request after a
|
|
|
+ * jittered delay, merged on top of the cache; nothing changes when the new
|
|
|
+ * post is not in this list. Own posts are not skipped: `EVENTS.feed` only
|
|
|
+ * reaches the tab that posted, and the merge ignores rows already cached.
|
|
|
+ */
|
|
|
+ useEffect(() => {
|
|
|
+ if (listenTimeline !== true) return undefined
|
|
|
+
|
|
|
+ let timer: ReturnType<typeof setTimeout> | null = null
|
|
|
+ let cancelled = false
|
|
|
+ const deletedIds = new Set<string>()
|
|
|
+
|
|
|
+ const loadNewItems = async () => {
|
|
|
+ timer = null
|
|
|
+ const cacheKey = qKeyRef.current
|
|
|
+ try {
|
|
|
+ const freshPage = await fetchUniListData({
|
|
|
+ pageParam: undefined,
|
|
|
+ requestUrl: requestUrlRef.current,
|
|
|
+ defaultParams: paramsRef.current,
|
|
|
+ })
|
|
|
+ if (cancelled || cacheKey !== qKeyRef.current) return
|
|
|
+ const page = deletedIds.size
|
|
|
+ ? { ...freshPage, data: (freshPage?.data ?? []).filter((it: any) => !deletedIds.has(String(it.id))) }
|
|
|
+ : freshPage
|
|
|
+ if (mergeFirstPageIntoUniListQueryCache(queryClient, cacheKey, page)) {
|
|
|
+ refetchRef.current.skipToast = true
|
|
|
+ setRequested(true)
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // The next event or refetch catches up.
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const offAdded = subscribe('bx_timeline_0', 'added', () => {
|
|
|
+ if (timer) return
|
|
|
+ // Jitter only spreads the load; not security-sensitive.
|
|
|
+ timer = setTimeout(loadNewItems, TIMELINE_BATCH_MS + Math.random() * TIMELINE_JITTER_MS) // NOSONAR
|
|
|
+ })
|
|
|
+
|
|
|
+ const offDeleted = subscribe('bx_timeline_0', 'deleted', (payload: unknown) => {
|
|
|
+ const id = parseTimelinePayload(payload)?.id
|
|
|
+ if (id == null) return
|
|
|
+ deletedIds.add(String(id))
|
|
|
+ if (deletedIds.size > TIMELINE_DELETED_MAX) deletedIds.delete(deletedIds.values().next().value as string)
|
|
|
+ const cacheKey = qKeyRef.current
|
|
|
+ const isShown = flattenPagesForUniList(queryClient.getQueryData(cacheKey)).some((it: any) => it.id == id)
|
|
|
+ if (isShown) {
|
|
|
+ refetchRef.current.skipToast = true
|
|
|
+ setRequested(true)
|
|
|
+ removeItemFromUniListQueryCache(queryClient, cacheKey, id)
|
|
|
+ }
|
|
|
+ setFrozen((f) => f?.items.some((it: any) => it.id == id)
|
|
|
+ ? { ...f, items: f.items.filter((it: any) => it.id != id) }
|
|
|
+ : f)
|
|
|
+ })
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ cancelled = true
|
|
|
+ if (timer) clearTimeout(timer)
|
|
|
+ offAdded()
|
|
|
+ offDeleted()
|
|
|
+ }
|
|
|
+ }, [listenTimeline, queryClient])
|
|
|
+
|
|
|
/*
|
|
|
* Visible rows are derived from the query cache instead of being copied into
|
|
|
* local state by effects (one extra commit per data change, plus a reset
|