about summary refs log tree commit diff
path: root/src/components/PostControls
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-09-04 17:30:15 -0500
committerGitHub <noreply@github.com>2025-09-04 17:30:15 -0500
commit535d4d6cf74cfb49a70804bccb4de1613d2ac09c (patch)
tree78198de5712398e5a9a4b43ec69b254f81081442 /src/components/PostControls
parent04b869714e512ed29653892d45dab806396824e1 (diff)
downloadvoidsky-535d4d6cf74cfb49a70804bccb4de1613d2ac09c.tar.zst
📓 Bookmarks (#8976)
* Add button to controls, respace

* Hook up shadow and mutation

* Add Bookmarks screen

* Build out Bookmarks screen

* Handle removals via shadow

* Use truncateAndInvalidate strategy

* Add empty state

* Add toasts

* Add undo buttons to toasts

* Stage NUX, needs image

* Finesse post controls

* New reply icon

* Use curvier variant of repost icon

* Prevent layout shift with align_start

* Update api pkg

* Swap in new image

* Limit spacing on desktop

* Rm decimals over 10k

* Better optimistic adding/removing

* Add metrics

* Comment

* Remove unused code block

* Remove debug limit

* Fork shadow for web/native

* Tweak alt

* add preventExpansion: true

* Refine hitslop

* Add count to anchor

* Reduce space in compact mode

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/components/PostControls')
-rw-r--r--src/components/PostControls/BookmarkButton.tsx136
-rw-r--r--src/components/PostControls/PostControlButton.tsx27
-rw-r--r--src/components/PostControls/PostMenu/index.tsx6
-rw-r--r--src/components/PostControls/RepostButton.tsx8
-rw-r--r--src/components/PostControls/ShareMenu/index.tsx6
-rw-r--r--src/components/PostControls/index.tsx222
-rw-r--r--src/components/PostControls/util.ts24
7 files changed, 322 insertions, 107 deletions
diff --git a/src/components/PostControls/BookmarkButton.tsx b/src/components/PostControls/BookmarkButton.tsx
new file mode 100644
index 000000000..70acebc05
--- /dev/null
+++ b/src/components/PostControls/BookmarkButton.tsx
@@ -0,0 +1,136 @@
+import {memo} from 'react'
+import {type Insets} from 'react-native'
+import {type AppBskyFeedDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type React from 'react'
+
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {type Shadow} from '#/state/cache/post-shadow'
+import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
+import {useTheme} from '#/alf'
+import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
+import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
+import * as toast from '#/components/Toast'
+import {PostControlButton, PostControlButtonIcon} from './PostControlButton'
+
+export const BookmarkButton = memo(function BookmarkButton({
+  post,
+  big,
+  logContext,
+  hitSlop,
+}: {
+  post: Shadow<AppBskyFeedDefs.PostView>
+  big?: boolean
+  logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+  hitSlop?: Insets
+}): React.ReactNode {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {mutateAsync: bookmark} = useBookmarkMutation()
+  const cleanError = useCleanError()
+
+  const {viewer} = post
+  const isBookmarked = !!viewer?.bookmarked
+
+  const undoLabel = _(
+    msg({
+      message: `Undo`,
+      context: `Button label to undo saving/removing a post from saved posts.`,
+    }),
+  )
+
+  const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
+    try {
+      await bookmark({
+        action: 'create',
+        post,
+      })
+
+      logger.metric('post:bookmark', {logContext})
+
+      toast.show(
+        <toast.Outer>
+          <toast.Icon />
+          <toast.Text>
+            <Trans>Post saved</Trans>
+          </toast.Text>
+          {!disableUndo && (
+            <toast.Action
+              label={undoLabel}
+              onPress={() => remove({disableUndo: true})}>
+              {undoLabel}
+            </toast.Action>
+          )}
+        </toast.Outer>,
+        {
+          type: 'success',
+        },
+      )
+    } catch (e: any) {
+      const {raw, clean} = cleanError(e)
+      toast.show(clean || raw || e, {
+        type: 'error',
+      })
+    }
+  }
+
+  const remove = async ({disableUndo}: {disableUndo?: boolean} = {}) => {
+    try {
+      await bookmark({
+        action: 'delete',
+        uri: post.uri,
+      })
+
+      logger.metric('post:unbookmark', {logContext})
+
+      toast.show(
+        <toast.Outer>
+          <toast.Icon icon={TrashIcon} />
+          <toast.Text>
+            <Trans>Removed from saved posts</Trans>
+          </toast.Text>
+          {!disableUndo && (
+            <toast.Action
+              label={undoLabel}
+              onPress={() => save({disableUndo: true})}>
+              {undoLabel}
+            </toast.Action>
+          )}
+        </toast.Outer>,
+      )
+    } catch (e: any) {
+      const {raw, clean} = cleanError(e)
+      toast.show(clean || raw || e, {
+        type: 'error',
+      })
+    }
+  }
+
+  const onHandlePress = async () => {
+    if (isBookmarked) {
+      await remove()
+    } else {
+      await save()
+    }
+  }
+
+  return (
+    <PostControlButton
+      testID="postBookmarkBtn"
+      big={big}
+      label={
+        isBookmarked
+          ? _(msg`Remove from saved posts`)
+          : _(msg`Add to saved posts`)
+      }
+      onPress={onHandlePress}
+      hitSlop={hitSlop}>
+      <PostControlButtonIcon
+        fill={isBookmarked ? t.palette.primary_500 : undefined}
+        icon={isBookmarked ? BookmarkFilled : Bookmark}
+      />
+    </PostControlButton>
+  )
+})
diff --git a/src/components/PostControls/PostControlButton.tsx b/src/components/PostControls/PostControlButton.tsx
index ae69b1322..f7070c4c8 100644
--- a/src/components/PostControls/PostControlButton.tsx
+++ b/src/components/PostControls/PostControlButton.tsx
@@ -1,13 +1,14 @@
 import {createContext, useContext, useMemo} from 'react'
-import {type GestureResponderEvent, type View} from 'react-native'
+import {type GestureResponderEvent, type Insets, type View} from 'react-native'
 
-import {POST_CTRL_HITSLOP} from '#/lib/constants'
 import {useHaptics} from '#/lib/haptics'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, type ButtonProps} from '#/components/Button'
 import {type Props as SVGIconProps} from '#/components/icons/common'
 import {Text, type TextProps} from '#/components/Typography'
 
+export const DEFAULT_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
+
 const PostControlContext = createContext<{
   big?: boolean
   active?: boolean
@@ -25,12 +26,13 @@ export function PostControlButton({
   active,
   activeColor,
   ...props
-}: ButtonProps & {
+}: Omit<ButtonProps, 'hitSlop'> & {
   ref?: React.Ref<View>
   active?: boolean
   big?: boolean
   color?: string
   activeColor?: string
+  hitSlop?: Insets
 }) {
   const t = useTheme()
   const playHaptic = useHaptics()
@@ -83,8 +85,11 @@ export function PostControlButton({
       shape="round"
       variant="ghost"
       color="secondary"
-      hitSlop={POST_CTRL_HITSLOP}
-      {...props}>
+      {...props}
+      hitSlop={{
+        ...DEFAULT_HITSLOP,
+        ...(props.hitSlop || {}),
+      }}>
       {typeof children === 'function' ? (
         args => (
           <PostControlContext.Provider value={ctx}>
@@ -102,12 +107,20 @@ export function PostControlButton({
 
 export function PostControlButtonIcon({
   icon: Comp,
-}: {
+  style,
+  ...rest
+}: SVGIconProps & {
   icon: React.ComponentType<SVGIconProps>
 }) {
   const {big, color} = useContext(PostControlContext)
 
-  return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} />
+  return (
+    <Comp
+      style={[color, a.pointer_events_none, style]}
+      {...rest}
+      width={big ? 22 : 18}
+    />
+  )
 }
 
 export function PostControlButtonText({style, ...props}: TextProps) {
diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx
index 63aa460fb..1102aa9a4 100644
--- a/src/components/PostControls/PostMenu/index.tsx
+++ b/src/components/PostControls/PostMenu/index.tsx
@@ -1,4 +1,5 @@
 import {memo, useMemo, useState} from 'react'
+import {type Insets} from 'react-native'
 import {
   type AppBskyFeedDefs,
   type AppBskyFeedPost,
@@ -28,6 +29,7 @@ let PostMenuButton = ({
   timestamp,
   threadgateRecord,
   onShowLess,
+  hitSlop,
 }: {
   testID: string
   post: Shadow<AppBskyFeedDefs.PostView>
@@ -39,6 +41,7 @@ let PostMenuButton = ({
   timestamp: string
   threadgateRecord?: AppBskyFeedThreadgate.Record
   onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
+  hitSlop?: Insets
 }): React.ReactNode => {
   const {_} = useLingui()
 
@@ -66,7 +69,8 @@ let PostMenuButton = ({
                 testID="postDropdownBtn"
                 big={big}
                 label={props.accessibilityLabel}
-                {...props}>
+                {...props}
+                hitSlop={hitSlop}>
                 <PostControlButtonIcon icon={DotsHorizontal} />
               </PostControlButton>
             )
diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx
index e09950b49..522e80dd3 100644
--- a/src/components/PostControls/RepostButton.tsx
+++ b/src/components/PostControls/RepostButton.tsx
@@ -5,12 +5,12 @@ import {useLingui} from '@lingui/react'
 
 import {useHaptics} from '#/lib/haptics'
 import {useRequireAuth} from '#/state/session'
-import {formatCount} from '#/view/com/util/numeric/format'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
-import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
+import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost'
+import {formatPostStatCount} from '#/components/PostControls/util'
 import {Text} from '#/components/Typography'
 import {
   PostControlButton,
@@ -25,6 +25,7 @@ interface Props {
   onQuote: () => void
   big?: boolean
   embeddingDisabled: boolean
+  compactCount?: boolean
 }
 
 let RepostButton = ({
@@ -34,6 +35,7 @@ let RepostButton = ({
   onQuote,
   big,
   embeddingDisabled,
+  compactCount,
 }: Props): React.ReactNode => {
   const t = useTheme()
   const {_, i18n} = useLingui()
@@ -86,7 +88,7 @@ let RepostButton = ({
         <PostControlButtonIcon icon={Repost} />
         {typeof repostCount !== 'undefined' && repostCount > 0 && (
           <PostControlButtonText testID="repostCount">
-            {formatCount(i18n, repostCount)}
+            {formatPostStatCount(i18n, repostCount, {compact: compactCount})}
           </PostControlButtonText>
         )}
       </PostControlButton>
diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx
index d4ea18bb0..6f59c0d42 100644
--- a/src/components/PostControls/ShareMenu/index.tsx
+++ b/src/components/PostControls/ShareMenu/index.tsx
@@ -1,4 +1,5 @@
 import {memo, useMemo, useState} from 'react'
+import {type Insets} from 'react-native'
 import {
   type AppBskyFeedDefs,
   type AppBskyFeedPost,
@@ -34,6 +35,7 @@ let ShareMenuButton = ({
   timestamp,
   threadgateRecord,
   onShare,
+  hitSlop,
 }: {
   testID: string
   post: Shadow<AppBskyFeedDefs.PostView>
@@ -43,6 +45,7 @@ let ShareMenuButton = ({
   timestamp: string
   threadgateRecord?: AppBskyFeedThreadgate.Record
   onShare: () => void
+  hitSlop?: Insets
 }): React.ReactNode => {
   const {_} = useLingui()
   const gate = useGate()
@@ -92,7 +95,8 @@ let ShareMenuButton = ({
                 big={big}
                 label={props.accessibilityLabel}
                 {...props}
-                onLongPress={native(onNativeLongPress)}>
+                onLongPress={native(onNativeLongPress)}
+                hitSlop={hitSlop}>
                 <PostControlButtonIcon icon={ShareIcon} />
               </PostControlButton>
             )
diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx
index 16330a682..834ad8e7d 100644
--- a/src/components/PostControls/index.tsx
+++ b/src/components/PostControls/index.tsx
@@ -24,10 +24,11 @@ import {
   ProgressGuideAction,
   useProgressGuideControls,
 } from '#/state/shell/progress-guide'
-import {formatCount} from '#/view/com/util/numeric/format'
 import * as Toast from '#/view/com/util/Toast'
-import {atoms as a, useBreakpoints} from '#/alf'
-import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
+import {atoms as a, flatten, useBreakpoints} from '#/alf'
+import {Reply as Bubble} from '#/components/icons/Reply'
+import {formatPostStatCount} from '#/components/PostControls/util'
+import {BookmarkButton} from './BookmarkButton'
 import {
   PostControlButton,
   PostControlButtonIcon,
@@ -51,6 +52,7 @@ let PostControls = ({
   threadgateRecord,
   onShowLess,
   viaRepost,
+  variant,
 }: {
   big?: boolean
   post: Shadow<AppBskyFeedDefs.PostView>
@@ -65,9 +67,9 @@ let PostControls = ({
   threadgateRecord?: AppBskyFeedThreadgate.Record
   onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
   viaRepost?: {uri: string; cid: string}
+  variant?: 'compact' | 'normal' | 'large'
 }): React.ReactNode => {
   const {_, i18n} = useLingui()
-  const {gtMobile} = useBreakpoints()
   const {openComposer} = useOpenComposer()
   const {feedDescriptor} = useFeedFeedbackContext()
   const [queueLike, queueUnlike] = usePostLikeMutationQueue(
@@ -92,6 +94,7 @@ let PostControls = ({
       post.author.viewer?.blockingByList,
   )
   const replyDisabled = post.viewer?.replyDisabled
+  const {gtPhone} = useBreakpoints()
 
   const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
 
@@ -184,6 +187,12 @@ let PostControls = ({
     })
   }
 
+  const secondaryControlSpacingStyles = flatten([
+    {gap: 0}, // default, we want `gap` to be defined on the resulting object
+    variant !== 'compact' && a.gap_xs,
+    (big || gtPhone) && a.gap_sm,
+  ])
+
   return (
     <View
       style={[
@@ -191,104 +200,124 @@ let PostControls = ({
         a.justify_between,
         a.align_center,
         !big && a.pt_2xs,
+        a.gap_md,
         style,
       ]}>
-      <View
-        style={[
-          big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
-          replyDisabled ? {opacity: 0.5} : undefined,
-        ]}>
-        <PostControlButton
-          testID="replyBtn"
-          onPress={
-            !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined
-          }
-          label={_(
-            msg({
-              message: `Reply (${plural(post.replyCount || 0, {
-                one: '# reply',
-                other: '# replies',
-              })})`,
-              comment:
-                'Accessibility label for the reply button, verb form followed by number of replies and noun form',
-            }),
-          )}
-          big={big}>
-          <PostControlButtonIcon icon={Bubble} />
-          {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
-            <PostControlButtonText>
-              {formatCount(i18n, post.replyCount)}
-            </PostControlButtonText>
-          )}
-        </PostControlButton>
-      </View>
-      <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
-        <RepostButton
-          isReposted={!!post.viewer?.repost}
-          repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
-          onRepost={onRepost}
-          onQuote={onQuote}
-          big={big}
-          embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
-        />
-      </View>
-      <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
-        <PostControlButton
-          testID="likeBtn"
-          big={big}
-          onPress={() => requireAuth(() => onPressToggleLike())}
-          label={
-            post.viewer?.like
-              ? _(
-                  msg({
-                    message: `Unlike (${plural(post.likeCount || 0, {
-                      one: '# like',
-                      other: '# likes',
-                    })})`,
-                    comment:
-                      'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
-                  }),
-                )
-              : _(
-                  msg({
-                    message: `Like (${plural(post.likeCount || 0, {
-                      one: '# like',
-                      other: '# likes',
-                    })})`,
-                    comment:
-                      'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
-                  }),
-                )
-          }>
-          <AnimatedLikeIcon
-            isLiked={Boolean(post.viewer?.like)}
-            big={big}
-            hasBeenToggled={hasLikeIconBeenToggled}
-          />
-          <CountWheel
-            likeCount={post.likeCount ?? 0}
+      <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
+        <View
+          style={[
+            a.flex_1,
+            a.align_start,
+            {marginLeft: big ? -2 : -6},
+            replyDisabled ? {opacity: 0.5} : undefined,
+          ]}>
+          <PostControlButton
+            testID="replyBtn"
+            onPress={
+              !replyDisabled
+                ? () => requireAuth(() => onPressReply())
+                : undefined
+            }
+            label={_(
+              msg({
+                message: `Reply (${plural(post.replyCount || 0, {
+                  one: '# reply',
+                  other: '# replies',
+                })})`,
+                comment:
+                  'Accessibility label for the reply button, verb form followed by number of replies and noun form',
+              }),
+            )}
+            big={big}>
+            <PostControlButtonIcon icon={Bubble} />
+            {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
+              <PostControlButtonText>
+                {formatPostStatCount(i18n, post.replyCount, {
+                  compact: variant === 'compact',
+                })}
+              </PostControlButtonText>
+            )}
+          </PostControlButton>
+        </View>
+        <View style={[a.flex_1, a.align_start]}>
+          <RepostButton
+            isReposted={!!post.viewer?.repost}
+            repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
+            onRepost={onRepost}
+            onQuote={onQuote}
             big={big}
-            isLiked={Boolean(post.viewer?.like)}
-            hasBeenToggled={hasLikeIconBeenToggled}
+            embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
+            compactCount={variant === 'compact'}
           />
-        </PostControlButton>
-      </View>
-      <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
-        <View style={[!big && a.ml_sm]}>
-          <ShareMenuButton
-            testID="postShareBtn"
-            post={post}
+        </View>
+        <View style={[a.flex_1, a.align_start]}>
+          <PostControlButton
+            testID="likeBtn"
             big={big}
-            record={record}
-            richText={richText}
-            timestamp={post.indexedAt}
-            threadgateRecord={threadgateRecord}
-            onShare={onShare}
-          />
+            onPress={() => requireAuth(() => onPressToggleLike())}
+            label={
+              post.viewer?.like
+                ? _(
+                    msg({
+                      message: `Unlike (${plural(post.likeCount || 0, {
+                        one: '# like',
+                        other: '# likes',
+                      })})`,
+                      comment:
+                        'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
+                    }),
+                  )
+                : _(
+                    msg({
+                      message: `Like (${plural(post.likeCount || 0, {
+                        one: '# like',
+                        other: '# likes',
+                      })})`,
+                      comment:
+                        'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
+                    }),
+                  )
+            }>
+            <AnimatedLikeIcon
+              isLiked={Boolean(post.viewer?.like)}
+              big={big}
+              hasBeenToggled={hasLikeIconBeenToggled}
+            />
+            <CountWheel
+              likeCount={post.likeCount ?? 0}
+              big={big}
+              isLiked={Boolean(post.viewer?.like)}
+              hasBeenToggled={hasLikeIconBeenToggled}
+              compactCount={variant === 'compact'}
+            />
+          </PostControlButton>
         </View>
+        {/* Spacer! */}
+        <View />
       </View>
-      <View
-        style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}>
+      <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
+        <BookmarkButton
+          post={post}
+          big={big}
+          logContext={logContext}
+          hitSlop={{
+            right: secondaryControlSpacingStyles.gap / 2,
+          }}
+        />
+        <ShareMenuButton
+          testID="postShareBtn"
+          post={post}
+          big={big}
+          record={record}
+          richText={richText}
+          timestamp={post.indexedAt}
+          threadgateRecord={threadgateRecord}
+          onShare={onShare}
+          hitSlop={{
+            left: secondaryControlSpacingStyles.gap / 2,
+            right: secondaryControlSpacingStyles.gap / 2,
+          }}
+        />
         <PostMenuButton
           testID="postDropdownBtn"
           post={post}
@@ -300,6 +329,9 @@ let PostControls = ({
           timestamp={post.indexedAt}
           threadgateRecord={threadgateRecord}
           onShowLess={onShowLess}
+          hitSlop={{
+            left: secondaryControlSpacingStyles.gap / 2,
+          }}
         />
       </View>
     </View>
diff --git a/src/components/PostControls/util.ts b/src/components/PostControls/util.ts
new file mode 100644
index 000000000..5d3ea74e4
--- /dev/null
+++ b/src/components/PostControls/util.ts
@@ -0,0 +1,24 @@
+import {type I18n} from '@lingui/core'
+
+/**
+ * This matches `formatCount` from `view/com/util/numeric/format.ts`, but has
+ * additional truncation logic for large numbers. `roundingMode` should always
+ * match the original impl, regardless of if we add more formatting here.
+ */
+export function formatPostStatCount(
+  i18n: I18n,
+  count: number,
+  {
+    compact = false,
+  }: {
+    compact?: boolean
+  } = {},
+): string {
+  const isOver10k = count >= 10_000
+  return i18n.number(count, {
+    notation: 'compact',
+    maximumFractionDigits: isOver10k || compact ? 0 : 1,
+    // @ts-expect-error - roundingMode not in the types
+    roundingMode: 'trunc',
+  })
+}