From 535d4d6cf74cfb49a70804bccb4de1613d2ac09c Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Thu, 4 Sep 2025 17:30:15 -0500
Subject: 📓 Bookmarks (#8976)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* 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>
---
 src/components/PostControls/BookmarkButton.tsx | 136 +++++++++++++++++++++++++
 1 file changed, 136 insertions(+)
 create mode 100644 src/components/PostControls/BookmarkButton.tsx

(limited to 'src/components/PostControls/BookmarkButton.tsx')

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>
+  )
+})
-- 
cgit 1.4.1

