forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1/**
2 * NOTE
3 * The ./unread.ts API:
4 *
5 * - Provides a `checkUnread()` function to sync with the server,
6 * - Periodically calls `checkUnread()`, and
7 * - Caches the first page of notifications.
8 *
9 * IMPORTANT: This query uses ./unread.ts's cache as its first page,
10 * IMPORTANT: which means the cache-freshness of this query is driven by the unread API.
11 *
12 * Follow these rules:
13 *
14 * 1. Call `checkUnread()` if you want to fetch latest in the background.
15 * 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately.
16 * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
17 */
18
19import {useCallback, useEffect, useMemo, useRef} from 'react'
20import {
21 AppBskyFeedDefs,
22 AppBskyFeedPost,
23 AtUri,
24 moderatePost,
25} from '@atproto/api'
26import {
27 type InfiniteData,
28 type QueryClient,
29 type QueryKey,
30 useInfiniteQuery,
31 useQueryClient,
32} from '@tanstack/react-query'
33
34import {useModerationOpts} from '#/state/preferences/moderation-opts'
35import {STALE} from '#/state/queries'
36import {useAgent} from '#/state/session'
37import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
38import type * as bsky from '#/types/bsky'
39import {
40 didOrHandleUriMatches,
41 embedViewRecordToPostView,
42 getEmbeddedPost,
43} from '../util'
44import {type FeedPage} from './types'
45import {useUnreadNotificationsApi} from './unread'
46import {fetchPage} from './util'
47
48export type {FeedNotification, FeedPage, NotificationType} from './types'
49
50const PAGE_SIZE = 30
51
52type RQPageParam = string | undefined
53
54const RQKEY_ROOT = 'notification-feed'
55export function RQKEY(filter: 'all' | 'mentions') {
56 return [RQKEY_ROOT, filter]
57}
58
59export function useNotificationFeedQuery(opts: {
60 enabled?: boolean
61 filter: 'all' | 'mentions'
62}) {
63 const agent = useAgent()
64 const queryClient = useQueryClient()
65 const moderationOpts = useModerationOpts()
66 const unreads = useUnreadNotificationsApi()
67 const enabled = opts.enabled !== false
68 const filter = opts.filter
69 const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()
70
71 const selectArgs = useMemo(() => {
72 return {
73 moderationOpts,
74 hiddenReplyUris,
75 }
76 }, [moderationOpts, hiddenReplyUris])
77 const lastRun = useRef<{
78 data: InfiniteData<FeedPage>
79 args: typeof selectArgs
80 result: InfiniteData<FeedPage>
81 } | null>(null)
82
83 const query = useInfiniteQuery<
84 FeedPage,
85 Error,
86 InfiniteData<FeedPage>,
87 QueryKey,
88 RQPageParam
89 >({
90 staleTime: STALE.INFINITY,
91 queryKey: RQKEY(filter),
92 async queryFn({pageParam}: {pageParam: RQPageParam}) {
93 let page
94 if (filter === 'all' && !pageParam) {
95 // for the first page, we check the cached page held by the unread-checker first
96 page = unreads.getCachedUnreadPage()
97 }
98 if (!page) {
99 let reasons: string[] = []
100 if (filter === 'mentions') {
101 reasons = [
102 // Anything that's a post
103 'mention',
104 'reply',
105 'quote',
106 ]
107 }
108 const {page: fetchedPage} = await fetchPage({
109 agent,
110 limit: PAGE_SIZE,
111 cursor: pageParam,
112 queryClient,
113 moderationOpts,
114 fetchAdditionalData: true,
115 reasons,
116 })
117 page = fetchedPage
118 }
119
120 if (filter === 'all' && !pageParam) {
121 // if the first page has an unread, mark all read
122 unreads.markAllRead()
123 }
124
125 return page
126 },
127 initialPageParam: undefined,
128 getNextPageParam: lastPage => lastPage.cursor,
129 enabled,
130 select: useCallback(
131 (data: InfiniteData<FeedPage>) => {
132 const {moderationOpts, hiddenReplyUris} = selectArgs
133
134 // Keep track of the last run and whether we can reuse
135 // some already selected pages from there.
136 let reusedPages = []
137 if (lastRun.current) {
138 const {
139 data: lastData,
140 args: lastArgs,
141 result: lastResult,
142 } = lastRun.current
143 let canReuse = true
144 for (let key in selectArgs) {
145 if (selectArgs.hasOwnProperty(key)) {
146 if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {
147 // Can't do reuse anything if any input has changed.
148 canReuse = false
149 break
150 }
151 }
152 }
153 if (canReuse) {
154 for (let i = 0; i < data.pages.length; i++) {
155 if (data.pages[i] && lastData.pages[i] === data.pages[i]) {
156 reusedPages.push(lastResult.pages[i])
157 continue
158 }
159 // Stop as soon as pages stop matching up.
160 break
161 }
162 }
163 }
164
165 // override 'isRead' using the first page's returned seenAt
166 // we do this because the `markAllRead()` call above will
167 // mark subsequent pages as read prematurely
168 const seenAt = data.pages[0]?.seenAt || new Date()
169 for (const page of data.pages) {
170 for (const item of page.items) {
171 item.notification.isRead =
172 seenAt > new Date(item.notification.indexedAt)
173 }
174 }
175
176 const result = {
177 ...data,
178 pages: [
179 ...reusedPages,
180 ...data.pages.slice(reusedPages.length).map(page => {
181 return {
182 ...page,
183 items: page.items
184 .filter(item => {
185 const isHiddenReply =
186 item.type === 'reply' &&
187 item.subjectUri &&
188 hiddenReplyUris.has(item.subjectUri)
189 return !isHiddenReply
190 })
191 .filter(item => {
192 if (
193 item.type === 'reply' ||
194 item.type === 'mention' ||
195 item.type === 'quote'
196 ) {
197 /*
198 * The `isPostView` check will fail here bc we don't have
199 * a `$type` field on the `subject`. But if the nested
200 * `record` is a post, we know it's a post view.
201 */
202 if (AppBskyFeedPost.isRecord(item.subject?.record)) {
203 const mod = moderatePost(item.subject, moderationOpts!)
204 if (mod.ui('contentList').filter) {
205 return false
206 }
207 }
208 }
209 return true
210 }),
211 }
212 }),
213 ],
214 }
215
216 lastRun.current = {data, result, args: selectArgs}
217
218 return result
219 },
220 [selectArgs],
221 ),
222 })
223
224 // The server may end up returning an empty page, a page with too few items,
225 // or a page with items that end up getting filtered out. When we fetch pages,
226 // we'll keep track of how many items we actually hope to see. If the server
227 // doesn't return enough items, we're going to continue asking for more items.
228 const lastItemCount = useRef(0)
229 const wantedItemCount = useRef(0)
230 const autoPaginationAttemptCount = useRef(0)
231 useEffect(() => {
232 const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
233 query
234 // Count the items that we already have.
235 let itemCount = 0
236 for (const page of data?.pages || []) {
237 itemCount += page.items.length
238 }
239
240 // If items got truncated, reset the state we're tracking below.
241 if (itemCount !== lastItemCount.current) {
242 if (itemCount < lastItemCount.current) {
243 wantedItemCount.current = itemCount
244 }
245 lastItemCount.current = itemCount
246 }
247
248 // Now track how many items we really want, and fetch more if needed.
249 if (isLoading || isRefetching) {
250 // During the initial fetch, we want to get an entire page's worth of items.
251 wantedItemCount.current = PAGE_SIZE
252 } else if (isFetchingNextPage) {
253 if (itemCount > wantedItemCount.current) {
254 // We have more items than wantedItemCount, so wantedItemCount must be out of date.
255 // Some other code must have called fetchNextPage(), for example, from onEndReached.
256 // Adjust the wantedItemCount to reflect that we want one more full page of items.
257 wantedItemCount.current = itemCount + PAGE_SIZE
258 }
259 } else if (hasNextPage) {
260 // At this point we're not fetching anymore, so it's time to make a decision.
261 // If we didn't receive enough items from the server, paginate again until we do.
262 if (itemCount < wantedItemCount.current) {
263 autoPaginationAttemptCount.current++
264 if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
265 query.fetchNextPage()
266 }
267 } else {
268 autoPaginationAttemptCount.current = 0
269 }
270 }
271 }, [query])
272
273 return query
274}
275
276export function* findAllPostsInQueryData(
277 queryClient: QueryClient,
278 uri: string,
279): Generator<AppBskyFeedDefs.PostView, void> {
280 const atUri = new AtUri(uri)
281
282 const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
283 queryKey: [RQKEY_ROOT],
284 })
285 for (const [_queryKey, queryData] of queryDatas) {
286 if (!queryData?.pages) {
287 continue
288 }
289
290 for (const page of queryData?.pages) {
291 for (const item of page.items) {
292 if (item.type !== 'starterpack-joined') {
293 if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
294 yield item.subject
295 }
296 }
297
298 if (AppBskyFeedDefs.isPostView(item.subject)) {
299 const quotedPost = getEmbeddedPost(item.subject?.embed)
300 if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
301 yield embedViewRecordToPostView(quotedPost!)
302 }
303 }
304 }
305 }
306 }
307}
308
309export function* findAllProfilesInQueryData(
310 queryClient: QueryClient,
311 did: string,
312): Generator<bsky.profile.AnyProfileView, void> {
313 const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
314 queryKey: [RQKEY_ROOT],
315 })
316 for (const [_queryKey, queryData] of queryDatas) {
317 if (!queryData?.pages) {
318 continue
319 }
320 for (const page of queryData?.pages) {
321 for (const item of page.items) {
322 if (
323 (item.type === 'follow' || item.type === 'contact-match') &&
324 item.notification.author.did === did
325 ) {
326 yield item.notification.author
327 } else if (
328 item.type !== 'starterpack-joined' &&
329 item.subject?.author.did === did
330 ) {
331 yield item.subject.author
332 }
333 if (AppBskyFeedDefs.isPostView(item.subject)) {
334 const quotedPost = getEmbeddedPost(item.subject?.embed)
335 if (quotedPost?.author.did === did) {
336 yield quotedPost.author
337 }
338 }
339 }
340 }
341 }
342}