1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
|
/* eslint-disable no-labels */
import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api'
import {
type ApiThreadItem,
type PostThreadParams,
type ThreadItem,
type TraversalMetadata,
} from '#/state/queries/usePostThread/types'
import {
getPostRecord,
getThreadPostNoUnauthenticatedUI,
getThreadPostUI,
getTraversalMetadata,
storeTraversalMetadata,
} from '#/state/queries/usePostThread/utils'
import * as views from '#/state/queries/usePostThread/views'
export function sortAndAnnotateThreadItems(
thread: ApiThreadItem[],
{
threadgateHiddenReplies,
moderationOpts,
view,
skipModerationHandling,
}: {
threadgateHiddenReplies: Set<string>
moderationOpts: ModerationOpts
view: PostThreadParams['view']
/**
* Set to `true` in cases where we already know the moderation state of the
* post e.g. when fetching additional replies from the server. This will
* prevent additional sorting or nested-branch truncation, and all replies,
* regardless of moderation state, will be included in the resulting
* `threadItems` array.
*/
skipModerationHandling?: boolean
},
) {
const threadItems: ThreadItem[] = []
const otherThreadItems: ThreadItem[] = []
const metadatas = new Map<string, TraversalMetadata>()
traversal: for (let i = 0; i < thread.length; i++) {
const item = thread[i]
let parentMetadata: TraversalMetadata | undefined
let metadata: TraversalMetadata | undefined
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
parentMetadata = metadatas.get(
getPostRecord(item.value.post).reply?.parent?.uri || '',
)
metadata = getTraversalMetadata({
item,
parentMetadata,
prevItem: thread.at(i - 1),
nextItem: thread.at(i + 1),
})
storeTraversalMetadata(metadatas, metadata)
}
if (item.depth < 0) {
/*
* Parents are ignored until we find the anchor post, then we walk
* _up_ from there.
*/
} else if (item.depth === 0) {
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) {
threadItems.push(views.threadPostNoUnauthenticated(item))
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) {
threadItems.push(views.threadPostNotFound(item))
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) {
threadItems.push(views.threadPostBlocked(item))
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
const post = views.threadPost({
uri: item.uri,
depth: item.depth,
value: item.value,
moderationOpts,
threadgateHiddenReplies,
})
threadItems.push(post)
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
const parent = thread[pi]
if (
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value)
) {
const post = views.threadPostNoUnauthenticated(parent)
post.ui = getThreadPostNoUnauthenticatedUI({
depth: parent.depth,
// ignore for now
// prevItemDepth: thread[pi - 1]?.depth,
nextItemDepth: thread[pi + 1]?.depth,
})
threadItems.unshift(post)
// for now, break parent traversal at first no-unauthed
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) {
threadItems.unshift(views.threadPostNotFound(parent))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) {
threadItems.unshift(views.threadPostBlocked(parent))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) {
threadItems.unshift(
views.threadPost({
uri: parent.uri,
depth: parent.depth,
value: parent.value,
moderationOpts,
threadgateHiddenReplies,
}),
)
}
}
}
} else if (item.depth > 0) {
/*
* The API does not send down any unavailable replies, so this will
* always be false (for now). If we ever wanted to tombstone them here,
* we could.
*/
const shouldBreak =
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) ||
AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) ||
AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)
if (shouldBreak) {
const branch = getBranch(thread, i, item.depth)
// could insert tombstone
i = branch.end
continue traversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
if (parentMetadata) {
/*
* Set this value before incrementing the parent's repliesSeenCounter
*/
metadata!.replyIndex = parentMetadata.repliesIndexCounter
// Increment the parent's repliesIndexCounter
parentMetadata.repliesIndexCounter += 1
}
const post = views.threadPost({
uri: item.uri,
depth: item.depth,
value: item.value,
moderationOpts,
threadgateHiddenReplies,
})
if (!post.isBlurred || skipModerationHandling) {
/*
* Not moderated, need to insert it
*/
threadItems.push(post)
/*
* Update seen reply count of parent
*/
if (parentMetadata) {
parentMetadata.repliesSeenCounter += 1
}
} else {
/*
* Moderated in some way, we're going to walk children
*/
const parent = post
const parentIsTopLevelReply = parent.depth === 1
// get sub tree
const branch = getBranch(thread, i, item.depth)
if (parentIsTopLevelReply) {
// push branch anchor into sorted array
otherThreadItems.push(parent)
// skip branch anchor in branch traversal
const startIndex = branch.start + 1
for (let ci = startIndex; ci <= branch.end; ci++) {
const child = thread[ci]
if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) {
const childParentMetadata = metadatas.get(
getPostRecord(child.value.post).reply?.parent?.uri || '',
)
const childMetadata = getTraversalMetadata({
item: child,
prevItem: thread[ci - 1],
nextItem: thread[ci + 1],
parentMetadata: childParentMetadata,
})
storeTraversalMetadata(metadatas, childMetadata)
if (childParentMetadata) {
/*
* Set this value before incrementing the parent's repliesIndexCounter
*/
childMetadata!.replyIndex =
childParentMetadata.repliesIndexCounter
childParentMetadata.repliesIndexCounter += 1
}
const childPost = views.threadPost({
uri: child.uri,
depth: child.depth,
value: child.value,
moderationOpts,
threadgateHiddenReplies,
})
/*
* If a child is moderated in any way, drop it an its sub-branch
* entirely. To reveal these, the user must navigate to the
* parent post directly.
*/
if (childPost.isBlurred) {
ci = getBranch(thread, ci, child.depth).end
} else {
otherThreadItems.push(childPost)
if (childParentMetadata) {
childParentMetadata.repliesSeenCounter += 1
}
}
} else {
/*
* Drop the rest of the branch if we hit anything unexpected
*/
break
}
}
}
/*
* Skip to next branch
*/
i = branch.end
continue traversal
}
}
}
}
/*
* Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute
* UI state based on collected metadata. These arrays will be muted in situ.
*/
for (const subset of [threadItems, otherThreadItems]) {
for (let i = 0; i < subset.length; i++) {
const item = subset[i]
const prevItem = subset.at(i - 1)
const nextItem = subset.at(i + 1)
if (item.type === 'threadPost') {
const metadata = metadatas.get(item.uri)
if (metadata) {
if (metadata.parentMetadata) {
/*
* Track what's before/after now that we've applied moderation
*/
if (prevItem?.type === 'threadPost')
metadata.prevItemDepth = prevItem?.depth
if (nextItem?.type === 'threadPost')
metadata.nextItemDepth = nextItem?.depth
/*
* We can now officially calculate `isLastSibling` and `isLastChild`
* based on the actual data that we've seen.
*/
metadata.isLastSibling =
metadata.replyIndex ===
metadata.parentMetadata.repliesSeenCounter - 1
metadata.isLastChild =
metadata.nextItemDepth === undefined ||
metadata.nextItemDepth <= metadata.depth
/*
* If this is the last sibling, it's implicitly part of the last
* branch of this sub-tree.
*/
if (metadata.isLastSibling) {
metadata.isPartOfLastBranchFromDepth = metadata.depth
/**
* If the parent is part of the last branch of the sub-tree, so is the child.
*/
if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
metadata.isPartOfLastBranchFromDepth =
metadata.parentMetadata.isPartOfLastBranchFromDepth
}
}
/*
* If this is the last sibling, and the parent has unhydrated replies,
* at some point down the line we will need to show a "read more".
*/
if (
metadata.parentMetadata.repliesUnhydrated > 0 &&
metadata.isLastSibling
) {
metadata.upcomingParentReadMore = metadata.parentMetadata
}
/*
* Copy in the parent's upcoming read more, if it exists. Once we
* reach the bottom, we'll insert a "read more"
*/
if (metadata.parentMetadata.upcomingParentReadMore) {
metadata.upcomingParentReadMore =
metadata.parentMetadata.upcomingParentReadMore
}
/*
* Copy in the parent's skipped indents
*/
metadata.skippedIndentIndices = new Set([
...metadata.parentMetadata.skippedIndentIndices,
])
/**
* If this is the last sibling, and the parent has no unhydrated
* replies, then we know we can skip an indent line.
*/
if (
metadata.parentMetadata.repliesUnhydrated <= 0 &&
metadata.isLastSibling
) {
/**
* Depth is 2 more than the 0-index of the indent calculation
* bc of how we render these. So instead of handling that in the
* component, we just adjust that back to 0-index here.
*/
metadata.skippedIndentIndices.add(item.depth - 2)
}
}
/*
* If this post has unhydrated replies, and it is the last child, then
* it itself needs a "read more"
*/
if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) {
metadata.precedesChildReadMore = true
subset.splice(i + 1, 0, views.readMore(metadata))
i++ // skip next iteration
}
/*
* Tree-view only.
*
* If there's an upcoming parent read more, this branch is part of the
* last branch of the sub-tree, and the item itself is the last child,
* insert the parent "read more".
*/
if (
view === 'tree' &&
metadata.upcomingParentReadMore &&
metadata.isPartOfLastBranchFromDepth ===
metadata.upcomingParentReadMore.depth &&
metadata.isLastChild
) {
subset.splice(
i + 1,
0,
views.readMore(metadata.upcomingParentReadMore),
)
i++
}
/**
* Only occurs for the first item in the thread, which may have
* additional parents not included in this request.
*/
if (item.value.moreParents) {
metadata.followsReadMoreUp = true
subset.splice(i, 0, views.readMoreUp(metadata))
i++
}
/*
* Calculate the final UI state for the thread item.
*/
item.ui = getThreadPostUI(metadata)
}
}
}
}
return {
threadItems,
otherThreadItems,
}
}
export function buildThread({
threadItems,
otherThreadItems,
serverOtherThreadItems,
isLoading,
hasSession,
otherItemsVisible,
hasOtherThreadItems,
showOtherItems,
}: {
threadItems: ThreadItem[]
otherThreadItems: ThreadItem[]
serverOtherThreadItems: ThreadItem[]
isLoading: boolean
hasSession: boolean
otherItemsVisible: boolean
hasOtherThreadItems: boolean
showOtherItems: () => void
}) {
/**
* `threadItems` is memoized here, so don't mutate it directly.
*/
const items = [...threadItems]
if (isLoading) {
const anchorPost = items.at(0)
const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost'
const skeletonReplies = hasAnchorFromCache
? anchorPost.value.post.replyCount ?? 4
: 4
if (!items.length) {
items.push(
views.skeleton({
key: 'anchor-skeleton',
item: 'anchor',
}),
)
}
if (hasSession) {
// we might have this from cache
const replyDisabled =
hasAnchorFromCache &&
anchorPost.value.post.viewer?.replyDisabled === true
if (hasAnchorFromCache) {
if (!replyDisabled) {
items.push({
type: 'replyComposer',
key: 'replyComposer',
})
}
} else {
items.push(
views.skeleton({
key: 'replyComposer',
item: 'replyComposer',
}),
)
}
}
for (let i = 0; i < skeletonReplies; i++) {
items.push(
views.skeleton({
key: `anchor-skeleton-reply-${i}`,
item: 'reply',
}),
)
}
} else {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (
item.type === 'threadPost' &&
item.depth === 0 &&
!item.value.post.viewer?.replyDisabled &&
hasSession
) {
items.splice(i + 1, 0, {
type: 'replyComposer',
key: 'replyComposer',
})
break
}
}
if (otherThreadItems.length || hasOtherThreadItems) {
if (otherItemsVisible) {
items.push(...otherThreadItems)
items.push(...serverOtherThreadItems)
} else {
items.push({
type: 'showOtherReplies',
key: 'showOtherReplies',
onPress: showOtherItems,
})
}
}
}
return items
}
/**
* Get the start and end index of a "branch" of the thread. A "branch" is a
* parent and it's children (not siblings). Returned indices are inclusive of
* the parent and its last child.
*
* items[] (index, depth)
* └─┬ anchor ──────── (0, 0)
* ├─── branch ───── (1, 1)
* ├──┬ branch ───── (2, 1) (start)
* │ ├──┬ leaf ──── (3, 2)
* │ │ └── leaf ── (4, 3)
* │ └─── leaf ──── (5, 2) (end)
* ├─── branch ───── (6, 1)
* └─── branch ───── (7, 1)
*
* const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1)
*/
export function getBranch(
thread: ApiThreadItem[],
branchStartIndex: number,
branchStartDepth: number,
) {
let end = branchStartIndex
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
const next = thread[ci]
if (next.depth > branchStartDepth) {
end = ci
} else {
end = ci - 1
break
}
}
return {
start: branchStartIndex,
end,
length: end - branchStartIndex,
}
}
|