Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 329 lines 9.2 kB view raw
1import {useCallback, useMemo, useState} from 'react' 2import {useQuery, useQueryClient} from '@tanstack/react-query' 3 4import {useModerationOpts} from '#/state/preferences/moderation-opts' 5import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' 6import { 7 LINEAR_VIEW_BELOW, 8 LINEAR_VIEW_BF, 9 TREE_VIEW_BELOW, 10 TREE_VIEW_BELOW_DESKTOP, 11 TREE_VIEW_BF, 12} from '#/state/queries/usePostThread/const' 13import {type PostThreadContextType} from '#/state/queries/usePostThread/context' 14import { 15 createCacheMutator, 16 getThreadPlaceholder, 17} from '#/state/queries/usePostThread/queryCache' 18import { 19 buildThread, 20 sortAndAnnotateThreadItems, 21} from '#/state/queries/usePostThread/traversal' 22import { 23 createPostThreadOtherQueryKey, 24 createPostThreadQueryKey, 25 type ThreadItem, 26 type UsePostThreadQueryResult, 27} from '#/state/queries/usePostThread/types' 28import {getThreadgateRecord} from '#/state/queries/usePostThread/utils' 29import * as views from '#/state/queries/usePostThread/views' 30import {useAgent, useSession} from '#/state/session' 31import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 32import {useBreakpoints} from '#/alf' 33import {IS_WEB} from '#/env' 34 35export * from '#/state/queries/usePostThread/context' 36export {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread/queryCache' 37export * from '#/state/queries/usePostThread/types' 38 39export function usePostThread({anchor}: {anchor?: string}) { 40 const qc = useQueryClient() 41 const agent = useAgent() 42 const {hasSession} = useSession() 43 const {gtPhone} = useBreakpoints() 44 const moderationOpts = useModerationOpts() 45 const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies() 46 const { 47 isLoaded: isThreadPreferencesLoaded, 48 sort, 49 setSort: baseSetSort, 50 view, 51 setView: baseSetView, 52 } = useThreadPreferences() 53 const below = useMemo(() => { 54 return view === 'linear' 55 ? LINEAR_VIEW_BELOW 56 : IS_WEB && gtPhone 57 ? TREE_VIEW_BELOW_DESKTOP 58 : TREE_VIEW_BELOW 59 }, [view, gtPhone]) 60 61 const postThreadQueryKey = createPostThreadQueryKey({ 62 anchor, 63 sort, 64 view, 65 }) 66 const postThreadOtherQueryKey = createPostThreadOtherQueryKey({ 67 anchor, 68 }) 69 70 const query = useQuery<UsePostThreadQueryResult>({ 71 enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, 72 queryKey: postThreadQueryKey, 73 async queryFn(ctx) { 74 const {data} = await agent.app.bsky.unspecced.getPostThreadV2({ 75 anchor: anchor!, 76 branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, 77 below, 78 sort: sort, 79 }) 80 81 /* 82 * Initialize `ctx.meta` to track if we know we have additional replies 83 * we could fetch once we hit the end. 84 */ 85 ctx.meta = ctx.meta || { 86 hasOtherReplies: false, 87 } 88 89 /* 90 * If we know we have additional replies, we'll set this to true. 91 */ 92 if (data.hasOtherReplies) { 93 ctx.meta.hasOtherReplies = true 94 } 95 96 const result = { 97 thread: data.thread || [], 98 threadgate: data.threadgate, 99 hasOtherReplies: !!ctx.meta.hasOtherReplies, 100 } 101 102 const record = getThreadgateRecord(result.threadgate) 103 if (result.threadgate && record) { 104 result.threadgate.record = record 105 } 106 107 return result as UsePostThreadQueryResult 108 }, 109 placeholderData() { 110 if (!anchor) return 111 const placeholder = getThreadPlaceholder(qc, anchor) 112 /* 113 * Always return something here, even empty data, so that 114 * `isPlaceholderData` is always true, which we'll use to insert 115 * skeletons. 116 */ 117 const thread = placeholder ? [placeholder] : [] 118 return {thread, threadgate: undefined, hasOtherReplies: false} 119 }, 120 select(data) { 121 const record = getThreadgateRecord(data.threadgate) 122 if (data.threadgate && record) { 123 data.threadgate.record = record 124 } 125 return data 126 }, 127 }) 128 129 const thread = useMemo(() => query.data?.thread || [], [query.data?.thread]) 130 const threadgate = useMemo( 131 () => query.data?.threadgate, 132 [query.data?.threadgate], 133 ) 134 const hasOtherThreadItems = useMemo( 135 () => !!query.data?.hasOtherReplies, 136 [query.data?.hasOtherReplies], 137 ) 138 const [otherItemsVisible, setOtherItemsVisible] = useState(false) 139 140 /** 141 * Creates a mutator for the post thread cache. This is used to insert 142 * replies into the thread cache after posting. 143 */ 144 const mutator = useMemo( 145 () => 146 createCacheMutator({ 147 params: {view, below}, 148 postThreadQueryKey, 149 postThreadOtherQueryKey, 150 queryClient: qc, 151 }), 152 [qc, view, below, postThreadQueryKey, postThreadOtherQueryKey], 153 ) 154 155 /** 156 * If we have additional items available from the server and the user has 157 * chosen to view them, start loading data 158 */ 159 const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible 160 const additionalItemsQuery = useQuery({ 161 enabled: additionalQueryEnabled, 162 queryKey: postThreadOtherQueryKey, 163 async queryFn() { 164 const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({ 165 anchor: anchor!, 166 }) 167 return data 168 }, 169 }) 170 const serverOtherThreadItems: ThreadItem[] = useMemo(() => { 171 if (!additionalQueryEnabled) return [] 172 if (additionalItemsQuery.isLoading) { 173 return Array.from({length: 2}).map((_, i) => 174 views.skeleton({ 175 key: `other-reply-${i}`, 176 item: 'reply', 177 }), 178 ) 179 } else if (additionalItemsQuery.isError) { 180 /* 181 * We could insert an special error component in here, but since these 182 * are optional additional replies, it's not critical that they're shown 183 * atm. 184 */ 185 return [] 186 } else if (additionalItemsQuery.data?.thread) { 187 const {threadItems} = sortAndAnnotateThreadItems( 188 additionalItemsQuery.data.thread, 189 { 190 view, 191 skipModerationHandling: true, 192 threadgateHiddenReplies: mergeThreadgateHiddenReplies( 193 threadgate?.record, 194 ), 195 moderationOpts: moderationOpts!, 196 }, 197 ) 198 return threadItems 199 } else { 200 return [] 201 } 202 }, [ 203 view, 204 additionalQueryEnabled, 205 additionalItemsQuery, 206 mergeThreadgateHiddenReplies, 207 moderationOpts, 208 threadgate?.record, 209 ]) 210 211 /** 212 * Sets the sort order for the thread and resets the additional thread items 213 */ 214 const setSort: typeof baseSetSort = useCallback( 215 nextSort => { 216 setOtherItemsVisible(false) 217 baseSetSort(nextSort) 218 }, 219 [baseSetSort, setOtherItemsVisible], 220 ) 221 222 /** 223 * Sets the view variant for the thread and resets the additional thread items 224 */ 225 const setView: typeof baseSetView = useCallback( 226 nextView => { 227 setOtherItemsVisible(false) 228 baseSetView(nextView) 229 }, 230 [baseSetView, setOtherItemsVisible], 231 ) 232 233 /* 234 * This is the main thread response, sorted into separate buckets based on 235 * moderation, and annotated with all UI state needed for rendering. 236 */ 237 const {threadItems, otherThreadItems} = useMemo(() => { 238 return sortAndAnnotateThreadItems(thread, { 239 view: view, 240 threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), 241 moderationOpts: moderationOpts!, 242 }) 243 }, [ 244 thread, 245 threadgate?.record, 246 mergeThreadgateHiddenReplies, 247 moderationOpts, 248 view, 249 ]) 250 251 /* 252 * Take all three sets of thread items and combine them into a single thread, 253 * along with any other thread items required for rendering e.g. "Show more 254 * replies" or the reply composer. 255 */ 256 const items = useMemo(() => { 257 return buildThread({ 258 threadItems, 259 otherThreadItems, 260 serverOtherThreadItems, 261 isLoading: query.isPlaceholderData, 262 hasSession, 263 hasOtherThreadItems, 264 otherItemsVisible, 265 showOtherItems: () => setOtherItemsVisible(true), 266 }) 267 }, [ 268 threadItems, 269 otherThreadItems, 270 serverOtherThreadItems, 271 query.isPlaceholderData, 272 hasSession, 273 hasOtherThreadItems, 274 otherItemsVisible, 275 setOtherItemsVisible, 276 ]) 277 278 return useMemo(() => { 279 const context: PostThreadContextType = { 280 postThreadQueryKey, 281 postThreadOtherQueryKey, 282 } 283 return { 284 context, 285 state: { 286 /* 287 * Copy in any query state that is useful 288 */ 289 isFetching: query.isFetching, 290 isPlaceholderData: query.isPlaceholderData, 291 error: query.error, 292 /* 293 * Other state 294 */ 295 sort, 296 view, 297 otherItemsVisible, 298 }, 299 data: { 300 items, 301 threadgate, 302 }, 303 actions: { 304 /* 305 * Copy in any query actions that are useful 306 */ 307 insertReplies: mutator.insertReplies, 308 refetch: query.refetch, 309 /* 310 * Other actions 311 */ 312 setSort, 313 setView, 314 }, 315 } 316 }, [ 317 query, 318 mutator.insertReplies, 319 otherItemsVisible, 320 sort, 321 view, 322 setSort, 323 setView, 324 threadgate, 325 items, 326 postThreadQueryKey, 327 postThreadOtherQueryKey, 328 ]) 329}