Bluesky app fork with some witchin' additions 馃挮
at readme-update 342 lines 11 kB view raw
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}