about summary refs log tree commit diff
path: root/src/screens/PostThread/components/ThreadItemPost.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/ThreadItemPost.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/ThreadItemPost.tsx')
-rw-r--r--src/screens/PostThread/components/ThreadItemPost.tsx405
1 files changed, 405 insertions, 0 deletions
diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx
new file mode 100644
index 000000000..1f63b10cd
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemPost.tsx
@@ -0,0 +1,405 @@
+import {memo, type ReactNode, useCallback, useMemo, useState} 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 {useActorStatus} from '#/lib/actor-status'
+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 {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
+import {
+  LINEAR_AVI_WIDTH,
+  OUTER_SPACE,
+  REPLY_LINE_WIDTH,
+} 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'
+
+export type ThreadItemPostProps = {
+  item: Extract<ThreadItem, {type: 'threadPost'}>
+  overrides?: {
+    moderation?: boolean
+    topBorder?: boolean
+  }
+  onPostSuccess?: (data: OnPostSuccessData) => void
+  threadgateRecord?: AppBskyFeedThreadgate.Record
+}
+
+export function ThreadItemPost({
+  item,
+  overrides,
+  onPostSuccess,
+  threadgateRecord,
+}: ThreadItemPostProps) {
+  const postShadow = usePostShadow(item.value.post)
+
+  if (postShadow === POST_TOMBSTONE) {
+    return <ThreadItemPostDeleted item={item} overrides={overrides} />
+  }
+
+  return (
+    <ThreadItemPostInner
+      item={item}
+      postShadow={postShadow}
+      threadgateRecord={threadgateRecord}
+      overrides={overrides}
+      onPostSuccess={onPostSuccess}
+    />
+  )
+}
+
+function ThreadItemPostDeleted({
+  item,
+  overrides,
+}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) {
+  const t = useTheme()
+
+  return (
+    <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
+      <ThreadItemPostParentReplyLine item={item} />
+
+      <View
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.py_md,
+          a.rounded_sm,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <View
+          style={[
+            a.flex_row,
+            a.align_center,
+            a.justify_center,
+            {
+              width: LINEAR_AVI_WIDTH,
+            },
+          ]}>
+          <TrashIcon style={[t.atoms.text_contrast_medium]} />
+        </View>
+        <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
+          <Trans>Post has been deleted</Trans>
+        </Text>
+      </View>
+
+      <View style={[{height: 4}]} />
+    </ThreadItemPostOuterWrapper>
+  )
+}
+
+const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
+  item,
+  overrides,
+  children,
+}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
+  children: ReactNode
+}) {
+  const t = useTheme()
+  const showTopBorder =
+    !item.ui.showParentReplyLine && overrides?.topBorder !== true
+
+  return (
+    <View
+      style={[
+        showTopBorder && [a.border_t, t.atoms.border_contrast_low],
+        {
+          paddingHorizontal: OUTER_SPACE,
+        },
+        // If there's no next child, add a little padding to bottom
+        !item.ui.showChildReplyLine &&
+          !item.ui.precedesChildReadMore && {
+            paddingBottom: OUTER_SPACE / 2,
+          },
+      ]}>
+      {children}
+    </View>
+  )
+})
+
+/**
+ * Provides some space between posts as well as contains the reply line
+ */
+const ThreadItemPostParentReplyLine = memo(
+  function ThreadItemPostParentReplyLine({
+    item,
+  }: Pick<ThreadItemPostProps, 'item'>) {
+    const t = useTheme()
+    return (
+      <View style={[a.flex_row, {height: 12}]}>
+        <View style={{width: LINEAR_AVI_WIDTH}}>
+          {item.ui.showParentReplyLine && (
+            <View
+              style={[
+                a.mx_auto,
+                a.flex_1,
+                a.mb_xs,
+                {
+                  width: REPLY_LINE_WIDTH,
+                  backgroundColor: t.atoms.border_contrast_low.borderColor,
+                },
+              ]}
+            />
+          )}
+        </View>
+      </View>
+    )
+  },
+)
+
+const ThreadItemPostInner = memo(function ThreadItemPostInner({
+  item,
+  postShadow,
+  overrides,
+  onPostSuccess,
+  threadgateRecord,
+}: ThreadItemPostProps & {
+  postShadow: Shadow<AppBskyFeedDefs.PostView>
+}) {
+  const t = useTheme()
+  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] = useState(
+    () => countLines(richText?.text) >= MAX_POST_LINES,
+  )
+  const threadRootUri = record.reply?.root?.uri || post.uri
+  const postHref = 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[] = 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 = 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 = useCallback(() => {
+    setLimitLines(false)
+  }, [setLimitLines])
+
+  const {isActive: live} = useActorStatus(post.author)
+
+  return (
+    <SubtleHover>
+      <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
+        <PostHider
+          testID={`postThreadItem-by-${post.author.handle}`}
+          href={postHref}
+          disabled={overrides?.moderation === true}
+          modui={moderation.ui('contentList')}
+          iconSize={LINEAR_AVI_WIDTH}
+          iconStyles={{marginLeft: 2, marginRight: 2}}
+          profile={post.author}
+          interpretFilterAsBlur>
+          <ThreadItemPostParentReplyLine item={item} />
+
+          <View style={[a.flex_row, a.gap_md]}>
+            <View>
+              <PreviewableUserAvatar
+                size={LINEAR_AVI_WIDTH}
+                profile={post.author}
+                moderation={moderation.ui('avatar')}
+                type={post.author.associated?.labeler ? 'labeler' : 'user'}
+                live={live}
+              />
+
+              {(item.ui.showChildReplyLine ||
+                item.ui.precedesChildReadMore) && (
+                <View
+                  style={[
+                    a.mx_auto,
+                    a.mt_xs,
+                    a.flex_1,
+                    {
+                      width: REPLY_LINE_WIDTH,
+                      backgroundColor: t.atoms.border_contrast_low.borderColor,
+                    },
+                  ]}
+                />
+              )}
+            </View>
+
+            <View style={[a.flex_1]}>
+              <PostMeta
+                author={post.author}
+                moderation={moderation}
+                timestamp={post.indexedAt}
+                postHref={postHref}
+                style={[a.pb_xs]}
+              />
+              <LabelsOnMyPost post={post} style={[a.pb_xs]} />
+              <PostAlerts
+                modui={moderation.ui('contentList')}
+                style={[a.pb_2xs]}
+                additionalCauses={additionalPostAlerts}
+              />
+              {richText?.text ? (
+                <RichText
+                  enableTags
+                  value={richText}
+                  style={[a.flex_1, a.text_md]}
+                  numberOfLines={limitLines ? MAX_POST_LINES : undefined}
+                  authorHandle={post.author.handle}
+                  shouldProxyLinks={true}
+                />
+              ) : 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>
+        </PostHider>
+      </ThreadItemPostOuterWrapper>
+    </SubtleHover>
+  )
+})
+
+function SubtleHover({children}: {children: ReactNode}) {
+  const {
+    state: hover,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+  return (
+    <View
+      onPointerEnter={onHoverIn}
+      onPointerLeave={onHoverOut}
+      style={a.pointer}>
+      <SubtleWebHover hover={hover} />
+      {children}
+    </View>
+  )
+}
+
+export function ThreadItemPostSkeleton({index}: {index: number}) {
+  const even = index % 2 === 0
+  return (
+    <View
+      style={[
+        {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
+        a.gap_md,
+      ]}>
+      <Skele.Row style={[a.align_start, a.gap_md]}>
+        <Skele.Circle size={LINEAR_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>
+  )
+}