Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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}