about summary refs log tree commit diff
path: root/src/screens/PostThread/index.tsx
blob: a4f94851ad55b7966b5f5f98b9d130d05450ab83 (plain) (blame)
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
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
import {useCallback, useMemo, useRef, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {Trans} from '@lingui/macro'

import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useFeedFeedback} from '#/state/feed-feedback'
import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {List, type ListMethods} from '#/view/com/util/List'
import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
import {ThreadError} from '#/screens/PostThread/components/ThreadError'
import {
  ThreadItemAnchor,
  ThreadItemAnchorSkeleton,
} from '#/screens/PostThread/components/ThreadItemAnchor'
import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
import {
  ThreadItemPost,
  ThreadItemPostSkeleton,
} from '#/screens/PostThread/components/ThreadItemPost'
import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
import {
  ThreadItemTreePost,
  ThreadItemTreePostSkeleton,
} from '#/screens/PostThread/components/ThreadItemTreePost'
import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'

const PARENT_CHUNK_SIZE = 5
const CHILDREN_CHUNK_SIZE = 50

export function PostThread({uri}: {uri: string}) {
  const {gtMobile} = useBreakpoints()
  const {hasSession} = useSession()
  const initialNumToRender = useInitialNumToRender() // TODO
  const {height: windowHeight} = useWindowDimensions()
  const anchorPostSource = useUnstablePostSource(uri)
  const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)

  /*
   * One query to rule them all
   */
  const thread = usePostThread({anchor: uri})
  const anchor = useMemo(() => {
    for (const item of thread.data.items) {
      if (item.type === 'threadPost' && item.depth === 0) {
        return item
      }
    }
    return
  }, [thread.data.items])

  const {openComposer} = useOpenComposer()
  const optimisticOnPostReply = useCallback(
    (payload: OnPostSuccessData) => {
      if (payload) {
        const {replyToUri, posts} = payload
        if (replyToUri && posts.length) {
          thread.actions.insertReplies(replyToUri, posts)
        }
      }
    },
    [thread],
  )
  const onReplyToAnchor = useCallback(() => {
    if (anchor?.type !== 'threadPost') {
      return
    }
    const post = anchor.value.post
    openComposer({
      replyTo: {
        uri: anchor.uri,
        cid: post.cid,
        text: post.record.text,
        author: post.author,
        embed: post.embed,
        moderation: anchor.moderation,
      },
      onPostSuccess: optimisticOnPostReply,
    })

    if (anchorPostSource) {
      feedFeedback.sendInteraction({
        item: post.uri,
        event: 'app.bsky.feed.defs#interactionReply',
        feedContext: anchorPostSource.post.feedContext,
        reqId: anchorPostSource.post.reqId,
      })
    }
  }, [
    anchor,
    openComposer,
    optimisticOnPostReply,
    anchorPostSource,
    feedFeedback,
  ])

  const isRoot = !!anchor && anchor.value.post.record.reply === undefined
  const canReply = !anchor?.value.post?.viewer?.replyDisabled
  const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
  const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
  const totalParentCount = useRef(0) // recomputed below
  const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
  const listRef = useRef<ListMethods>(null)
  const anchorRef = useRef<View | null>(null)
  const headerRef = useRef<View | null>(null)

  /*
   * On a cold load, parents are not prepended until the anchor post has
   * rendered as the first item in the list. This gives us a consistent
   * reference point for which to pin the anchor post to the top of the screen.
   *
   * We simulate a cold load any time the user changes the view or sort params
   * so that this handling is consistent.
   *
   * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives
   * us this for free, since the anchor post is the first item in the list.
   *
   * On web, `onContentSizeChange` is used to get ahead of next paint and handle
   * this scrolling.
   */
  const [deferParents, setDeferParents] = useState(true)
  /**
   * Used to flag whether we should scroll to the anchor post. On a cold load,
   * this is always true. And when a user changes thread parameters, we also
   * manually set this to true.
   */
  const shouldHandleScroll = useRef(true)
  /**
   * Called any time the content size of the list changes, _just_ before paint.
   *
   * We want this to fire every time we change params (which will reset
   * `deferParents` via `onLayout` on the anchor post, due to the key change),
   * or click into a new post (which will result in a fresh `deferParents`
   * hook).
   *
   * The result being: any intentional change in view by the user will result
   * in the anchor being pinned as the first item.
   */
  const onContentSizeChangeWebOnly = web(() => {
    const list = listRef.current
    const anchor = anchorRef.current as any as Element
    const header = headerRef.current as any as Element

    if (list && anchor && header && shouldHandleScroll.current) {
      const anchorOffsetTop = anchor.getBoundingClientRect().top
      const headerHeight = header.getBoundingClientRect().height

      /*
       * `deferParents` is `true` on a cold load, and always reset to
       * `true` when params change via `prepareForParamsUpdate`.
       *
       * On a cold load or a push to a new post, on the first pass of this
       * logic, the anchor post is the first item in the list. Therefore
       * `anchorOffsetTop - headerHeight` will be 0.
       *
       * When a user changes thread params, on the first pass of this logic,
       * the anchor post may not move (if there are no parents above it), or it
       * may have gone off the screen above, because of the sudden lack of
       * parents due to `deferParents === true`. This negative value (minus
       * `headerHeight`) will result in a _negative_ `offset` value, which will
       * scroll the anchor post _down_ to the top of the screen.
       *
       * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
       * changes params, the anchor post's offset will actually be equivalent
       * to the `headerHeight` because of how the DOM is stacked on web.
       * Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
       * which means the first pass in this case will result in no scroll.
       *
       * Then, once parents are prepended, this will fire again. Now, the
       * `anchorOffsetTop` will be positive, which minus the header height,
       * will give us a _positive_ offset, which will scroll the anchor post
       * back _up_ to the top of the screen.
       */
      list.scrollToOffset({
        offset: anchorOffsetTop - headerHeight,
      })

      /*
       * After the second pass, `deferParents` will be `false`, and we need
       * to ensure this doesn't run again until scroll handling is requested
       * again via `shouldHandleScroll.current === true` and a params
       * change via `prepareForParamsUpdate`.
       *
       * The `isRoot` here is needed because if we're looking at the anchor
       * post, this handler will not fire after `deferParents` is set to
       * `false`, since there are no parents to render above it. In this case,
       * we want to make sure `shouldHandleScroll` is set to `false` so that
       * subsequent size changes unrelated to a params change (like pagination)
       * do not affect scroll.
       */
      if (!deferParents || isRoot) shouldHandleScroll.current = false
    }
  })

  /**
   * Ditto the above, but for native.
   */
  const onContentSizeChangeNativeOnly = native(() => {
    const list = listRef.current
    const anchor = anchorRef.current

    if (list && anchor && shouldHandleScroll.current) {
      /*
       * `prepareForParamsUpdate` is called any time the user changes thread params like
       * `view` or `sort`, which sets `deferParents(true)` and resets the
       * scroll to the top of the list. However, there is a split second
       * where the top of the list is wherever the parents _just were_. So if
       * there were parents, the anchor is not at the top of the list just
       * prior to this handler being called.
       *
       * Once this handler is called, the anchor post is the first item in
       * the list (because of `deferParents` being `true`), and so we can
       * synchronously scroll the list back to the top of the list (which is
       * 0 on native, no need to handle `headerHeight`).
       */
      list.scrollToOffset({
        animated: false,
        offset: 0,
      })

      /*
       * After this first pass, `deferParents` will be `false`, and those
       * will render in. However, the anchor post will retain its position
       * because of `maintainVisibleContentPosition` handling on native. So we
       * don't need to let this handler run again, like we do on web.
       */
      shouldHandleScroll.current = false
    }
  })

  /**
   * Called any time the user changes thread params, such as `view` or `sort`.
   * Prepares the UI for repositioning of the scroll so that the anchor post is
   * always at the top after a params change.
   *
   * No need to handle max parents here, deferParents will handle that and we
   * want it to re-render with the same items above the anchor.
   */
  const prepareForParamsUpdate = useCallback(() => {
    /**
     * Truncate list so that anchor post is the first item in the list. Manual
     * scroll handling on web is predicated on this, and on native, this allows
     * `maintainVisibleContentPosition` to do its thing.
     */
    setDeferParents(true)
    // reset this to a lower value for faster re-render
    setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
    // set flag
    shouldHandleScroll.current = true
  }, [setDeferParents, setMaxChildrenCount])

  const setSortWrapped = useCallback(
    (sort: string) => {
      prepareForParamsUpdate()
      thread.actions.setSort(sort)
    },
    [thread, prepareForParamsUpdate],
  )

  const setViewWrapped = useCallback(
    (view: ThreadViewOption) => {
      prepareForParamsUpdate()
      thread.actions.setView(view)
    },
    [thread, prepareForParamsUpdate],
  )

  const onStartReached = () => {
    if (thread.state.isFetching) return
    // can be true after `prepareForParamsUpdate` is called
    if (deferParents) return
    // prevent any state mutations if we know we're done
    if (maxParentCount >= totalParentCount.current) return
    setMaxParentCount(n => n + PARENT_CHUNK_SIZE)
  }

  const onEndReached = () => {
    if (thread.state.isFetching) return
    // can be true after `prepareForParamsUpdate` is called
    if (deferParents) return
    // prevent any state mutations if we know we're done
    if (maxChildrenCount >= totalChildrenCount.current) return
    setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE)
  }

  const slices = useMemo(() => {
    const results: ThreadItem[] = []

    if (!thread.data.items.length) return results

    /*
     * Pagination hack, tracks the # of items below the anchor post.
     */
    let childrenCount = 0

    for (let i = 0; i < thread.data.items.length; i++) {
      const item = thread.data.items[i]
      /*
       * Need to check `depth`, since not found or blocked posts are not
       * `threadPost`s, but still have `depth`.
       */
      const hasDepth = 'depth' in item

      /*
       * Handle anchor post.
       */
      if (hasDepth && item.depth === 0) {
        results.push(item)

        // Recalculate total parents current index.
        totalParentCount.current = i
        // Recalculate total children using (length - 1) - current index.
        totalChildrenCount.current = thread.data.items.length - 1 - i

        /*
         * Walk up the parents, limiting by `maxParentCount`
         */
        if (!deferParents) {
          const start = i - 1
          if (start >= 0) {
            const limit = Math.max(0, start - maxParentCount)
            for (let pi = start; pi >= limit; pi--) {
              results.unshift(thread.data.items[pi])
            }
          }
        }
      } else {
        // ignore any parent items
        if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue
        // can exit early if we've reached the max children count
        if (childrenCount > maxChildrenCount) break

        results.push(item)
        childrenCount++
      }
    }

    return results
  }, [thread, deferParents, maxParentCount, maxChildrenCount])

  const isTombstoneView = useMemo(() => {
    if (slices.length > 1) return false
    return slices.every(
      s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
    )
  }, [slices])

  const renderItem = useCallback(
    ({item, index}: {item: ThreadItem; index: number}) => {
      if (item.type === 'threadPost') {
        if (item.depth < 0) {
          return (
            <ThreadItemPost
              item={item}
              threadgateRecord={thread.data.threadgate?.record ?? undefined}
              overrides={{
                topBorder: index === 0,
              }}
              onPostSuccess={optimisticOnPostReply}
            />
          )
        } else if (item.depth === 0) {
          return (
            /*
             * Keep this view wrapped so that the anchor post is always index 0
             * in the list and `maintainVisibleContentPosition` can do its
             * thing.
             */
            <View collapsable={false}>
              <View
                /*
                 * IMPORTANT: this is a load-bearing key on all platforms. We
                 * want to force `onLayout` to fire any time the thread params
                 * change so that `deferParents` is always reset to `false` once
                 * the anchor post is rendered.
                 *
                 * If we ever add additional thread params to this screen, they
                 * will need to be added here.
                 */
                key={item.uri + thread.state.view + thread.state.sort}
                ref={anchorRef}
                onLayout={() => setDeferParents(false)}
              />
              <ThreadItemAnchor
                item={item}
                threadgateRecord={thread.data.threadgate?.record ?? undefined}
                onPostSuccess={optimisticOnPostReply}
                postSource={anchorPostSource}
              />
            </View>
          )
        } else {
          if (thread.state.view === 'tree') {
            return (
              <ThreadItemTreePost
                item={item}
                threadgateRecord={thread.data.threadgate?.record ?? undefined}
                overrides={{
                  moderation: thread.state.otherItemsVisible && item.depth > 0,
                }}
                onPostSuccess={optimisticOnPostReply}
              />
            )
          } else {
            return (
              <ThreadItemPost
                item={item}
                threadgateRecord={thread.data.threadgate?.record ?? undefined}
                overrides={{
                  moderation: thread.state.otherItemsVisible && item.depth > 0,
                }}
                onPostSuccess={optimisticOnPostReply}
              />
            )
          }
        }
      } else if (item.type === 'threadPostNoUnauthenticated') {
        if (item.depth < 0) {
          return <ThreadItemPostNoUnauthenticated item={item} />
        } else if (item.depth === 0) {
          return <ThreadItemAnchorNoUnauthenticated />
        }
      } else if (item.type === 'readMore') {
        return (
          <ThreadItemReadMore
            item={item}
            view={thread.state.view === 'tree' ? 'tree' : 'linear'}
          />
        )
      } else if (item.type === 'readMoreUp') {
        return <ThreadItemReadMoreUp item={item} />
      } else if (item.type === 'threadPostBlocked') {
        return <ThreadItemPostTombstone type="blocked" />
      } else if (item.type === 'threadPostNotFound') {
        return <ThreadItemPostTombstone type="not-found" />
      } else if (item.type === 'replyComposer') {
        return (
          <View>
            {gtMobile && (
              <PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
            )}
          </View>
        )
      } else if (item.type === 'showOtherReplies') {
        return <ThreadItemShowOtherReplies onPress={item.onPress} />
      } else if (item.type === 'skeleton') {
        if (item.item === 'anchor') {
          return <ThreadItemAnchorSkeleton />
        } else if (item.item === 'reply') {
          if (thread.state.view === 'linear') {
            return <ThreadItemPostSkeleton index={index} />
          } else {
            return <ThreadItemTreePostSkeleton index={index} />
          }
        } else if (item.item === 'replyComposer') {
          return <ThreadItemReplyComposerSkeleton />
        }
      }
      return null
    },
    [
      thread,
      optimisticOnPostReply,
      onReplyToAnchor,
      gtMobile,
      anchorPostSource,
    ],
  )

  return (
    <>
      <Layout.Header.Outer headerRef={headerRef}>
        <Layout.Header.BackButton />
        <Layout.Header.Content>
          <Layout.Header.TitleText>
            <Trans context="description">Post</Trans>
          </Layout.Header.TitleText>
        </Layout.Header.Content>
        <Layout.Header.Slot>
          <HeaderDropdown
            sort={thread.state.sort}
            setSort={setSortWrapped}
            view={thread.state.view}
            setView={setViewWrapped}
          />
        </Layout.Header.Slot>
      </Layout.Header.Outer>

      {thread.state.error ? (
        <ThreadError
          error={thread.state.error}
          onRetry={thread.actions.refetch}
        />
      ) : (
        <List
          ref={listRef}
          data={slices}
          renderItem={renderItem}
          keyExtractor={keyExtractor}
          onContentSizeChange={platform({
            web: onContentSizeChangeWebOnly,
            default: onContentSizeChangeNativeOnly,
          })}
          onStartReached={onStartReached}
          onEndReached={onEndReached}
          onEndReachedThreshold={2}
          onStartReachedThreshold={1}
          /**
           * NATIVE ONLY
           * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
           */
          maintainVisibleContentPosition={{minIndexForVisible: 0}}
          desktopFixedHeight
          ListFooterComponent={
            <ListFooter
              /*
               * On native, if `deferParents` is true, we need some extra buffer to
               * account for the `on*ReachedThreshold` values.
               *
               * Otherwise, and on web, this value needs to be the height of
               * the viewport _minus_ a sensible min-post height e.g. 200, so
               * that there's enough scroll remaining to get the anchor post
               * back to the top of the screen when handling scroll.
               */
              height={platform({
                web: windowHeight - 200,
                default: deferParents ? windowHeight * 2 : windowHeight - 200,
              })}
              style={isTombstoneView ? {borderTopWidth: 0} : undefined}
            />
          }
          initialNumToRender={initialNumToRender}
          windowSize={11}
          sideBorders={false}
        />
      )}

      {!gtMobile && canReply && hasSession && (
        <MobileComposePrompt onPressReply={onReplyToAnchor} />
      )}
    </>
  )
}

function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
  const {footerHeight} = useShellLayout()

  const animatedStyle = useAnimatedStyle(() => {
    return {
      bottom: footerHeight.get(),
    }
  })

  return (
    <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
      <PostThreadComposePrompt onPressCompose={onPressReply} />
    </Animated.View>
  )
}

const keyExtractor = (item: ThreadItem) => {
  return item.key
}