about summary refs log tree commit diff
path: root/src/screens/PostThread/components/ThreadItemTreePost.tsx
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-06-11 14:32:14 -0500
committerGitHub <noreply@github.com>2025-06-11 14:32:14 -0500
commit61004b887b0c7515837e051144b694fc7db5a1cc (patch)
tree08cda716a97867480996f21d384824987fe3c15b /src/screens/PostThread/components/ThreadItemTreePost.tsx
parent143d5f3b814f1ce707fdfc87dabff7af5349bd06 (diff)
downloadvoidsky-61004b887b0c7515837e051144b694fc7db5a1cc.tar.zst
[Threads V2] Preliminary integration of unspecced V2 APIs (#8443)
* WIP

* Sorting working

* Rough handling of hidden/muted

* Better muted/hidden sorting and handling

* Clarify some naming

* Fix parents

* Handle first reply under highlighted/composer

* WIP RaW

* WIP optimistic

* Optimistic WIP

* Little cleanup, inserting dupes

* Re-org

* Add in new optimistic insert logic

* Update types

* Sorta working linear view optimistic state

* Simple working version, no pref for OP

* Working optimistic reply insertions, preference for OP

* Ensure deletes are coming through

* WIP scroll handling

* WIP scroll tweaks

* Clean up scrolling

* Clean up onPostSuccess

* Add annotations

* Fix highlighted post calc

* WIP kill me

* Update APIs

* Nvm don't kill me

* Fix optimistic insert

* Handle read more cases in tree view

* Basically working read more

* Handle linear view

* Reorg

* More reorg

* Split up thread post components

* New reply tree layout

* Fix up traversal metadata

* Tighten some spacing

* Use indent ya idiot

* Some linear mode cleanup

* Fix lines on read more items

* Vibe coding to success

* Almost there with read mores

* Update APIs

* Bump sdk

* Update import

* Checkpoint new traversal

* Checkpoint cleanup

* Checkpoint, need to fix blocked posts

* Checkpoint: think we're good, needs more cleanup

* Clean it up

* Two passes only

* Set to default params, update comment

* Fix render bug on native

* Checkpoint parent rendering, can opt for slower handling here

* Clean up parent handling, reply handling

* Fix read more extra space

* Fix read more in linear view

* Fix hidden reply handling, seen count, before/after calc

* Update naming

* Rename Slice to ThreadItem

* Add basic post and anchor skeletons

* Refactor client-side hidden

* WIP hidden fetching

* Update types

* Clean up query a bit

* Scrolling still broken

* Ok maybe fix scrolling

* Checkpoint move state into meta query

* Don't load remote hidden items unless needed

* skeleton view

* Reset hidden items when params change

* Split up traversal and avoid multiple passes

* Clean up

* Checkpoint: handling exhausted replies

* Clean up traversal functions further

* Clean up pagination

* Limit optimistic reply depth

* Handle optimistic insert in hidden replies

* Share root query key for easier cache extraction

* Make blurred posts not look like ass

* Fix double deleted item

* Make optimistic deleted state not look like crap in tree view

* Fix parents traversal 4 real

* Rename tree post

* Make optimistic deletions of linear posts not look bad

* Rename linear post components

* Handle tombstone views

* Rename read more component

* Add moreParents handling

* Align interaction states of read more

* Fix read more on FF

* Tree view skeleton

* Reply composer skele

* Remove hack for showing more replies

* Checkpoint: sort change scrolling fixed

* Checkpoint: learned new things, reset to base

* Feature gate

* Rename

* Replace show more

* Update settings screen

* Update pkg and endpoint

* Remove console

* Eureka

* Cleanup last commit

* No tests atm

* Remove scroll provider

* Clean up callbacks, better error state

* Remove todo

* Remove todo

* Remove todos

* Format

* Ok I think scrolling is solid

* Add back mobile compose input

* Ok need to compute headerHeight every time

* Update comments

* Ok button up web too

* Threads v2 tweaks (#8467)

* fix error screen collapsing

* use personx icon for blocked posts

* Remove height/width

* Revert unused Header change

* Clarify code

* Relate consts to theme values

* Remove debug code

* Typo

* Fix debounce of threads prefs

* Update metadata comments, dev mode

* Missed a spot

* Clean up todo

* Fix up no-unauthenticated posts

* Truncate parents if no-unauth

* Update getBranch docs

* Remove debug code

* Expand fetching in some cases

* Clear scroll need for root post to fix jump bug

* Fix reply composer skeleton state

* Remove uneeded initialized value

* Add profile shadow cache

* Some metrics

* prettier tweak

* eslint ignore

* Fix optimistic insertion

* Typo

* Rename, comment

* Remove wait

* Counter naming

* Replies seen counter for moderated sub-trees

* Remove borders on skeleton

* Align tombstone with optimistic deletion state

* Fix optimistic deletion for thread

* Add tree view icon

* Rename

* Cleanup

* Update settings copy

* Header menu open metric

* Bump package

* Better reply prompt (#8474)

* restyle reply prompt

* hide bottom bar border for cleaner look

* use new border hiding hook in DMs

* create `transparentifyColor` function

* adjust padding

* fix padding in immersive lpayer

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Integrate post-source

(cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9)

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src/screens/PostThread/components/ThreadItemTreePost.tsx')
-rw-r--r--src/screens/PostThread/components/ThreadItemTreePost.tsx456
1 files changed, 456 insertions, 0 deletions
diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx
new file mode 100644
index 000000000..d86d2ef6f
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx
@@ -0,0 +1,456 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+  type AppBskyFeedDefs,
+  type AppBskyFeedThreadgate,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {MAX_POST_LINES} from '#/lib/constants'
+import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {countLines} from '#/lib/strings/helpers'
+import {
+  POST_TOMBSTONE,
+  type Shadow,
+  usePostShadow,
+} from '#/state/cache/post-shadow'
+import {type ThreadItem} from '#/state/queries/usePostThread/types'
+import {useSession} from '#/state/session'
+import {type OnPostSuccessData} from '#/state/shell/composer'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {TextLink} from '#/view/com/util/Link'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {
+  OUTER_SPACE,
+  REPLY_LINE_WIDTH,
+  TREE_AVI_WIDTH,
+  TREE_INDENT,
+} from '#/screens/PostThread/const'
+import {atoms as a, useTheme} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {PostHider} from '#/components/moderation/PostHider'
+import {type AppModerationCause} from '#/components/Pills'
+import {PostControls} from '#/components/PostControls'
+import {RichText} from '#/components/RichText'
+import * as Skele from '#/components/Skeleton'
+import {SubtleWebHover} from '#/components/SubtleWebHover'
+import {Text} from '#/components/Typography'
+
+/**
+ * Mimic the space in PostMeta
+ */
+const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap
+
+export function ThreadItemTreePost({
+  item,
+  overrides,
+  onPostSuccess,
+  threadgateRecord,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+  overrides?: {
+    moderation?: boolean
+    topBorder?: boolean
+  }
+  onPostSuccess?: (data: OnPostSuccessData) => void
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+}) {
+  const postShadow = usePostShadow(item.value.post)
+
+  if (postShadow === POST_TOMBSTONE) {
+    return <ThreadItemTreePostDeleted item={item} />
+  }
+
+  return (
+    <ThreadItemTreePostInner
+      // Safeguard from clobbering per-post state below:
+      key={postShadow.uri}
+      item={item}
+      postShadow={postShadow}
+      threadgateRecord={threadgateRecord}
+      overrides={overrides}
+      onPostSuccess={onPostSuccess}
+    />
+  )
+}
+
+function ThreadItemTreePostDeleted({
+  item,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+}) {
+  const t = useTheme()
+  return (
+    <ThreadItemTreePostOuterWrapper item={item}>
+      <ThreadItemTreePostInnerWrapper item={item}>
+        <View
+          style={[
+            a.flex_row,
+            a.align_center,
+            a.rounded_sm,
+            t.atoms.bg_contrast_25,
+            {
+              gap: 6,
+              paddingHorizontal: OUTER_SPACE / 2,
+              height: TREE_AVI_WIDTH,
+            },
+          ]}>
+          <TrashIcon style={[t.atoms.text]} width={14} />
+          <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
+            <Trans>Post has been deleted</Trans>
+          </Text>
+        </View>
+        {item.ui.isLastChild && !item.ui.precedesChildReadMore && (
+          <View style={{height: OUTER_SPACE / 2}} />
+        )}
+      </ThreadItemTreePostInnerWrapper>
+    </ThreadItemTreePostOuterWrapper>
+  )
+}
+
+const ThreadItemTreePostOuterWrapper = memo(
+  function ThreadItemTreePostOuterWrapper({
+    item,
+    children,
+  }: {
+    item: Extract<ThreadItem, {type: 'threadPost'}>
+    children: React.ReactNode
+  }) {
+    const t = useTheme()
+    const indents = Math.max(0, item.ui.indent - 1)
+
+    return (
+      <View
+        style={[
+          a.flex_row,
+          item.ui.indent === 1 &&
+            !item.ui.showParentReplyLine && [
+              a.border_t,
+              t.atoms.border_contrast_low,
+            ],
+        ]}>
+        {Array.from(Array(indents)).map((_, n: number) => {
+          const isSkipped = item.ui.skippedIndentIndices.has(n)
+          return (
+            <View
+              key={`${item.value.post.uri}-padding-${n}`}
+              style={[
+                t.atoms.border_contrast_low,
+                {
+                  borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
+                  width: TREE_INDENT + TREE_AVI_WIDTH / 2,
+                  left: 1,
+                },
+              ]}
+            />
+          )
+        })}
+        {children}
+      </View>
+    )
+  },
+)
+
+const ThreadItemTreePostInnerWrapper = memo(
+  function ThreadItemTreePostInnerWrapper({
+    item,
+    children,
+  }: {
+    item: Extract<ThreadItem, {type: 'threadPost'}>
+    children: React.ReactNode
+  }) {
+    const t = useTheme()
+    return (
+      <View
+        style={[
+          a.flex_1, // TODO check on ios
+          {
+            paddingHorizontal: OUTER_SPACE,
+            paddingTop: OUTER_SPACE / 2,
+          },
+          item.ui.indent === 1 && [
+            !item.ui.showParentReplyLine && a.pt_lg,
+            !item.ui.showChildReplyLine && a.pb_sm,
+          ],
+          item.ui.isLastChild &&
+            !item.ui.precedesChildReadMore && [
+              {
+                paddingBottom: OUTER_SPACE / 2,
+              },
+            ],
+        ]}>
+        {item.ui.indent > 1 && (
+          <View
+            style={[
+              a.absolute,
+              t.atoms.border_contrast_low,
+              {
+                left: -1,
+                top: 0,
+                height:
+                  TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2,
+                width: OUTER_SPACE,
+                borderLeftWidth: REPLY_LINE_WIDTH,
+                borderBottomWidth: REPLY_LINE_WIDTH,
+                borderBottomLeftRadius: a.rounded_sm.borderRadius,
+              },
+            ]}
+          />
+        )}
+        {children}
+      </View>
+    )
+  },
+)
+
+const ThreadItemTreeReplyChildReplyLine = memo(
+  function ThreadItemTreeReplyChildReplyLine({
+    item,
+  }: {
+    item: Extract<ThreadItem, {type: 'threadPost'}>
+  }) {
+    const t = useTheme()
+    return (
+      <View style={[a.relative, {width: TREE_AVI_PLUS_SPACE}]}>
+        {item.ui.showChildReplyLine && (
+          <View
+            style={[
+              a.flex_1,
+              t.atoms.border_contrast_low,
+              {
+                borderRightWidth: 2,
+                width: '50%',
+                left: -1,
+              },
+            ]}
+          />
+        )}
+      </View>
+    )
+  },
+)
+
+const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
+  item,
+  postShadow,
+  overrides,
+  onPostSuccess,
+  threadgateRecord,
+}: {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+  postShadow: Shadow<AppBskyFeedDefs.PostView>
+  overrides?: {
+    moderation?: boolean
+    topBorder?: boolean
+  }
+  onPostSuccess?: (data: OnPostSuccessData) => void
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+}): React.ReactNode {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {openComposer} = useOpenComposer()
+  const {currentAccount} = useSession()
+
+  const post = item.value.post
+  const record = item.value.post.record
+  const moderation = item.moderation
+  const richText = useMemo(
+    () =>
+      new RichTextAPI({
+        text: record.text,
+        facets: record.facets,
+      }),
+    [record],
+  )
+  const [limitLines, setLimitLines] = React.useState(
+    () => countLines(richText?.text) >= MAX_POST_LINES,
+  )
+  const threadRootUri = record.reply?.root?.uri || post.uri
+  const postHref = React.useMemo(() => {
+    const urip = new AtUri(post.uri)
+    return makeProfileLink(post.author, 'post', urip.rkey)
+  }, [post.uri, post.author])
+  const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+    threadgateRecord,
+  })
+  const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
+    const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+    const isControlledByViewer =
+      new AtUri(threadRootUri).host === currentAccount?.did
+    return isControlledByViewer && isPostHiddenByThreadgate
+      ? [
+          {
+            type: 'reply-hidden',
+            source: {type: 'user', did: currentAccount?.did},
+            priority: 6,
+          },
+        ]
+      : []
+  }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
+
+  const onPressReply = React.useCallback(() => {
+    openComposer({
+      replyTo: {
+        uri: post.uri,
+        cid: post.cid,
+        text: record.text,
+        author: post.author,
+        embed: post.embed,
+        moderation,
+      },
+      onPostSuccess: onPostSuccess,
+    })
+  }, [openComposer, post, record, onPostSuccess, moderation])
+
+  const onPressShowMore = React.useCallback(() => {
+    setLimitLines(false)
+  }, [setLimitLines])
+
+  return (
+    <ThreadItemTreePostOuterWrapper item={item}>
+      <SubtleHover>
+        <PostHider
+          testID={`postThreadItem-by-${post.author.handle}`}
+          href={postHref}
+          disabled={overrides?.moderation === true}
+          modui={moderation.ui('contentList')}
+          iconSize={42}
+          iconStyles={{marginLeft: 2, marginRight: 2}}
+          profile={post.author}
+          interpretFilterAsBlur>
+          <ThreadItemTreePostInnerWrapper item={item}>
+            <View style={[a.flex_1]}>
+              <PostMeta
+                author={post.author}
+                moderation={moderation}
+                timestamp={post.indexedAt}
+                postHref={postHref}
+                avatarSize={TREE_AVI_WIDTH}
+                style={[a.pb_2xs]}
+                showAvatar
+              />
+              <View style={[a.flex_row]}>
+                <ThreadItemTreeReplyChildReplyLine item={item} />
+                <View style={[a.flex_1]}>
+                  <LabelsOnMyPost post={post} style={[a.pb_2xs]} />
+                  <PostAlerts
+                    modui={moderation.ui('contentList')}
+                    style={[a.pb_2xs]}
+                    additionalCauses={additionalPostAlerts}
+                  />
+                  {richText?.text ? (
+                    <View>
+                      <RichText
+                        enableTags
+                        value={richText}
+                        style={[a.flex_1, a.text_md]}
+                        numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                        authorHandle={post.author.handle}
+                        shouldProxyLinks={true}
+                      />
+                    </View>
+                  ) : undefined}
+                  {limitLines ? (
+                    <TextLink
+                      text={_(msg`Show More`)}
+                      style={pal.link}
+                      onPress={onPressShowMore}
+                      href="#"
+                    />
+                  ) : undefined}
+                  {post.embed && (
+                    <View style={[a.pb_xs]}>
+                      <PostEmbeds
+                        embed={post.embed}
+                        moderation={moderation}
+                        viewContext={PostEmbedViewContext.Feed}
+                      />
+                    </View>
+                  )}
+                  <PostControls
+                    post={postShadow}
+                    record={record}
+                    richText={richText}
+                    onPressReply={onPressReply}
+                    logContext="PostThreadItem"
+                    threadgateRecord={threadgateRecord}
+                  />
+                </View>
+              </View>
+            </View>
+          </ThreadItemTreePostInnerWrapper>
+        </PostHider>
+      </SubtleHover>
+    </ThreadItemTreePostOuterWrapper>
+  )
+})
+
+function SubtleHover({children}: {children: React.ReactNode}) {
+  const {
+    state: hover,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  return (
+    <View
+      onPointerEnter={onHoverIn}
+      onPointerLeave={onHoverOut}
+      style={[a.flex_1, a.pointer]}>
+      <SubtleWebHover hover={hover} />
+      {children}
+    </View>
+  )
+}
+
+export function ThreadItemTreePostSkeleton({index}: {index: number}) {
+  const t = useTheme()
+  const even = index % 2 === 0
+  return (
+    <View
+      style={[
+        {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
+        a.gap_md,
+        a.border_t,
+        t.atoms.border_contrast_low,
+      ]}>
+      <Skele.Row style={[a.align_start, a.gap_md]}>
+        <Skele.Circle size={TREE_AVI_WIDTH} />
+
+        <Skele.Col style={[a.gap_xs]}>
+          <Skele.Row style={[a.gap_sm]}>
+            <Skele.Text style={[a.text_md, {width: '20%'}]} />
+            <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
+          </Skele.Row>
+
+          <Skele.Col>
+            {even ? (
+              <>
+                <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
+                <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
+              </>
+            ) : (
+              <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
+            )}
+          </Skele.Col>
+
+          <Skele.Row style={[a.justify_between, a.pt_xs]}>
+            <Skele.Pill blend size={16} />
+            <Skele.Pill blend size={16} />
+            <Skele.Pill blend size={16} />
+            <Skele.Circle blend size={16} />
+            <View />
+          </Skele.Row>
+        </Skele.Col>
+      </Skele.Row>
+    </View>
+  )
+}