about summary refs log tree commit diff
path: root/src
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
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')
-rw-r--r--src/Navigation.tsx9
-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
-rw-r--r--src/components/dialogs/nuxs/BookmarksAnnouncement.tsx177
-rw-r--r--src/components/dialogs/nuxs/index.tsx13
-rw-r--r--src/components/icons/Bookmark.tsx16
-rw-r--r--src/components/icons/Reply.tsx11
-rw-r--r--src/lib/constants.ts1
-rw-r--r--src/lib/custom-animations/CountWheel.tsx12
-rw-r--r--src/lib/hooks/useNavigationTabState.ts1
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/logger/metrics.ts8
-rw-r--r--src/routes.ts1
-rw-r--r--src/screens/Bookmarks/components/EmptyState.tsx59
-rw-r--r--src/screens/Bookmarks/index.tsx294
-rw-r--r--src/screens/PostThread/components/ThreadItemAnchor.tsx33
-rw-r--r--src/screens/PostThread/components/ThreadItemTreePost.tsx1
-rw-r--r--src/state/cache/post-shadow.ts16
-rw-r--r--src/state/queries/bookmarks/useBookmarkMutation.ts65
-rw-r--r--src/state/queries/bookmarks/useBookmarksQuery.ts114
-rw-r--r--src/state/queries/nuxs/definitions.ts6
-rw-r--r--src/view/com/post/Post.tsx8
-rw-r--r--src/view/shell/Drawer.tsx37
-rw-r--r--src/view/shell/desktop/LeftNav.tsx19
29 files changed, 1203 insertions, 128 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 7af38105b..cdc0fc220 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -71,6 +71,7 @@ import {SupportScreen} from '#/view/screens/Support'
 import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
 import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
 import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
+import {BookmarksScreen} from '#/screens/Bookmarks'
 import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
 import HashtagScreen from '#/screens/Hashtag'
 import {LogScreen} from '#/screens/Log'
@@ -600,6 +601,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
           requireAuth: true,
         }}
       />
+      <Stack.Screen
+        name="Bookmarks"
+        getComponent={() => BookmarksScreen}
+        options={{
+          title: title(msg`Saved Posts`),
+          requireAuth: true,
+        }}
+      />
     </>
   )
 }
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',
+  })
+}
diff --git a/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx b/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx
new file mode 100644
index 000000000..c63558334
--- /dev/null
+++ b/src/components/dialogs/nuxs/BookmarksAnnouncement.tsx
@@ -0,0 +1,177 @@
+import {useCallback} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {LinearGradient} from 'expo-linear-gradient'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isWeb} from '#/platform/detection'
+import {atoms as a, useTheme, web} from '#/alf'
+import {transparentifyColor} from '#/alf/util/colorGeneration'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {useNuxDialogContext} from '#/components/dialogs/nuxs'
+import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
+import {Text} from '#/components/Typography'
+
+export function BookmarksAnnouncement() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const nuxDialogs = useNuxDialogContext()
+  const control = Dialog.useDialogControl()
+
+  Dialog.useAutoOpen(control)
+
+  const onClose = useCallback(() => {
+    nuxDialogs.dismissActiveNux()
+  }, [nuxDialogs])
+
+  return (
+    <Dialog.Outer
+      control={control}
+      onClose={onClose}
+      nativeOptions={{preventExpansion: true}}>
+      <Dialog.Handle />
+
+      <Dialog.ScrollableInner
+        label={_(msg`Introducing saved posts AKA bookmarks`)}
+        style={[web({maxWidth: 440})]}
+        contentContainerStyle={[
+          {
+            paddingTop: 0,
+            paddingLeft: 0,
+            paddingRight: 0,
+          },
+        ]}>
+        <View
+          style={[
+            a.align_center,
+            a.overflow_hidden,
+            {
+              gap: 16,
+              paddingTop: isWeb ? 24 : 40,
+              borderTopLeftRadius: a.rounded_md.borderRadius,
+              borderTopRightRadius: a.rounded_md.borderRadius,
+            },
+          ]}>
+          <LinearGradient
+            colors={[t.palette.primary_25, t.palette.primary_100]}
+            locations={[0, 1]}
+            start={{x: 0, y: 0}}
+            end={{x: 0, y: 1}}
+            style={[a.absolute, a.inset_0]}
+          />
+          <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+            <SparkleIcon fill={t.palette.primary_800} size="sm" />
+            <Text
+              style={[
+                a.font_bold,
+                {
+                  color: t.palette.primary_800,
+                },
+              ]}>
+              <Trans>New Feature</Trans>
+            </Text>
+          </View>
+
+          <View
+            style={[
+              a.relative,
+              a.w_full,
+              {
+                paddingTop: 8,
+                paddingHorizontal: 32,
+                paddingBottom: 32,
+              },
+            ]}>
+            <View
+              style={[
+                {
+                  borderRadius: 24,
+                  aspectRatio: 333 / 104,
+                },
+                isWeb
+                  ? [
+                      {
+                        boxShadow: `0px 10px 15px -3px ${transparentifyColor(t.palette.black, 0.2)}`,
+                      },
+                    ]
+                  : [
+                      t.atoms.shadow_md,
+                      {
+                        shadowOpacity: 0.2,
+                        shadowOffset: {
+                          width: 0,
+                          height: 10,
+                        },
+                      },
+                    ],
+              ]}>
+              <Image
+                accessibilityIgnoresInvertColors
+                source={require('../../../../assets/images/bookmarks_announcement_nux.webp')}
+                style={[
+                  a.w_full,
+                  {
+                    aspectRatio: 333 / 104,
+                  },
+                ]}
+                alt={_(
+                  msg`A screenshot of a post with a new button next to the share button that allows you to save the post to your bookmarks. The post is from @jcsalterego.bsky.social and reads "inventing a saturday that immediately follows monday".`,
+                )}
+              />
+            </View>
+          </View>
+        </View>
+        <View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
+          <View style={[a.gap_sm, a.align_center]}>
+            <Text
+              style={[
+                a.text_3xl,
+                a.leading_tight,
+                a.font_heavy,
+                a.text_center,
+                {
+                  fontSize: isWeb ? 28 : 32,
+                  maxWidth: 300,
+                },
+              ]}>
+              <Trans>Saved Posts</Trans>
+            </Text>
+            <Text
+              style={[
+                a.text_md,
+                a.leading_snug,
+                a.text_center,
+                {
+                  maxWidth: 340,
+                },
+              ]}>
+              <Trans>
+                Finally! Keep track of posts that matter to you. Save them to
+                revisit anytime.
+              </Trans>
+            </Text>
+          </View>
+
+          {!isWeb && (
+            <Button
+              label={_(msg`Close`)}
+              size="large"
+              color="primary"
+              onPress={() => {
+                control.close()
+              }}
+              style={[a.w_full]}>
+              <ButtonText>
+                <Trans>Close</Trans>
+              </ButtonText>
+            </Button>
+          )}
+        </View>
+
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx
index 985d58eec..bb15c5f63 100644
--- a/src/components/dialogs/nuxs/index.tsx
+++ b/src/components/dialogs/nuxs/index.tsx
@@ -12,12 +12,11 @@ import {
 import {useProfileQuery} from '#/state/queries/profile'
 import {type SessionAccount, useSession} from '#/state/session'
 import {useOnboardingState} from '#/state/shell'
-import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
+import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement'
 /*
  * NUXs
  */
 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
-import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils'
 
 type Context = {
   activeNux: Nux | undefined
@@ -34,13 +33,7 @@ const queuedNuxs: {
   }) => boolean
 }[] = [
   {
-    id: Nux.ActivitySubscriptions,
-    enabled: ({currentProfile}) => {
-      return isExistingUserAsOf(
-        '2025-07-07T00:00:00.000Z',
-        currentProfile.createdAt,
-      )
-    },
+    id: Nux.BookmarksAnnouncement,
   },
 ]
 
@@ -180,7 +173,7 @@ function Inner({
   return (
     <Context.Provider value={ctx}>
       {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
-      {activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />}
+      {activeNux === Nux.BookmarksAnnouncement && <BookmarksAnnouncement />}
     </Context.Provider>
   )
 }
diff --git a/src/components/icons/Bookmark.tsx b/src/components/icons/Bookmark.tsx
new file mode 100644
index 000000000..c8fb8242f
--- /dev/null
+++ b/src/components/icons/Bookmark.tsx
@@ -0,0 +1,16 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+// custom, not part of icon library
+export const Bookmark = createSinglePathSVG({
+  path: 'M9.7 16.895a4 4 0 0 1 4.6 0l3.7 2.6V6.5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v12.995l3.7-2.6Zm10.3 2.6c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2.001 2.001 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12.995Z',
+})
+
+// custom, not part of icon library
+export const BookmarkFilled = createSinglePathSVG({
+  path: 'M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2.001 2.001 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z',
+})
+
+// custom, not part of icon library, for LARGE (64px) size
+export const BookmarkDeleteLarge = createSinglePathSVG({
+  path: 'M14.2 2.625c.834 0 1.482 0 2.001.042.523.043.949.131 1.331.326.635.324 1.151.84 1.475 1.475.195.382.283.807.326 1.33.042.52.042 1.168.042 2.002v11.09c0 .495 0 .893-.027 1.199-.028.301-.087.585-.26.809-.249.323-.63.518-1.037.533-.282.01-.547-.107-.808-.26-.265-.154-.588-.385-.991-.673l-3.54-2.528c-.36-.258-.461-.322-.559-.347a.626.626 0 0 0-.306 0c-.098.025-.199.09-.559.347l-3.54 2.528c-.403.288-.726.519-.991.674-.261.152-.526.269-.808.259a1.376 1.376 0 0 1-1.038-.534c-.172-.223-.231-.507-.259-.808a7.31 7.31 0 0 1-.024-.528l-.003-.67V7.8c0-.834 0-1.482.042-2.001.043-.523.13-.949.325-1.331a3.376 3.376 0 0 1 1.476-1.475c.382-.195.808-.283 1.33-.326.52-.042 1.168-.042 2.002-.042h4.4Zm-4.4.75c-.846 0-1.458 0-1.94.04-.477.039-.792.114-1.051.246A2.626 2.626 0 0 0 5.66 4.81c-.132.259-.208.574-.247 1.051-.04.482-.039 1.094-.039 1.94v11.09l.003.658c.003.186.01.34.021.473.025.267.07.37.106.418a.626.626 0 0 0 .472.243c.059.002.168-.022.4-.158.23-.133.52-.34.935-.636l3.54-2.529c.308-.22.543-.396.81-.464.222-.056.454-.056.676 0 .267.068.5.244.81.464l3.54 2.529c.414.296.704.503.933.636.233.137.343.16.402.158a.626.626 0 0 0 .472-.243c.036-.048.081-.15.106-.419.024-.263.024-.62.024-1.13V7.8c0-.846 0-1.458-.04-1.94-.039-.477-.114-.792-.246-1.051A2.627 2.627 0 0 0 17.19 3.66c-.259-.132-.575-.207-1.051-.246-.482-.04-1.094-.04-1.94-.04H9.8Zm4.056 4.238a.375.375 0 0 1 .53.53L12.53 10l1.857 1.856a.375.375 0 0 1-.53.53L12 10.53l-1.856 1.857a.375.375 0 0 1-.53-.53L11.47 10 9.613 8.144a.375.375 0 0 1 .53-.53L12 9.47l1.856-1.857Z',
+})
diff --git a/src/components/icons/Reply.tsx b/src/components/icons/Reply.tsx
new file mode 100644
index 000000000..0317dd2f3
--- /dev/null
+++ b/src/components/icons/Reply.tsx
@@ -0,0 +1,11 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+// custom, off spec
+export const Reply = createSinglePathSVG({
+  path: 'M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z',
+})
+
+// custom, off spec
+export const ReplyFilled = createSinglePathSVG({
+  path: 'M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z',
+})
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index b6b06ee7f..5871821f4 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -124,7 +124,6 @@ export const createHitslop = (size: number): Insets => ({
 export const HITSLOP_10 = createHitslop(10)
 export const HITSLOP_20 = createHitslop(20)
 export const HITSLOP_30 = createHitslop(30)
-export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
 export const LANG_DROPDOWN_HITSLOP = {top: 10, bottom: 10, left: 4, right: 4}
 export const BACK_HITSLOP = HITSLOP_30
 export const MAX_POST_LINES = 25
diff --git a/src/lib/custom-animations/CountWheel.tsx b/src/lib/custom-animations/CountWheel.tsx
index 4b131db2d..6db22554e 100644
--- a/src/lib/custom-animations/CountWheel.tsx
+++ b/src/lib/custom-animations/CountWheel.tsx
@@ -10,9 +10,9 @@ import {i18n} from '@lingui/core'
 
 import {decideShouldRoll} from '#/lib/custom-animations/util'
 import {s} from '#/lib/styles'
-import {formatCount} from '#/view/com/util/numeric/format'
 import {Text} from '#/view/com/util/text/Text'
 import {atoms as a, useTheme} from '#/alf'
+import {formatPostStatCount} from '#/components/PostControls/util'
 
 const animationConfig = {
   duration: 400,
@@ -92,11 +92,13 @@ export function CountWheel({
   big,
   isLiked,
   hasBeenToggled,
+  compactCount,
 }: {
   likeCount: number
   big?: boolean
   isLiked: boolean
   hasBeenToggled: boolean
+  compactCount?: boolean
 }) {
   const t = useTheme()
   const shouldAnimate = !useReducedMotion() && hasBeenToggled
@@ -109,8 +111,12 @@ export function CountWheel({
   const [key, setKey] = React.useState(0)
   const [prevCount, setPrevCount] = React.useState(likeCount)
   const prevIsLiked = React.useRef(isLiked)
-  const formattedCount = formatCount(i18n, likeCount)
-  const formattedPrevCount = formatCount(i18n, prevCount)
+  const formattedCount = formatPostStatCount(i18n, likeCount, {
+    compact: compactCount,
+  })
+  const formattedPrevCount = formatPostStatCount(i18n, prevCount, {
+    compact: compactCount,
+  })
 
   React.useEffect(() => {
     if (isLiked === prevIsLiked.current) {
diff --git a/src/lib/hooks/useNavigationTabState.ts b/src/lib/hooks/useNavigationTabState.ts
index 2d15bce56..7fd76cb1b 100644
--- a/src/lib/hooks/useNavigationTabState.ts
+++ b/src/lib/hooks/useNavigationTabState.ts
@@ -9,6 +9,7 @@ export function useNavigationTabState() {
       isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
       // FeedsTab no longer exists, but this check works for `Feeds` screen as well
       isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
+      isAtBookmarks: getTabState(state, 'Bookmarks') !== TabState.Outside,
       isAtNotifications:
         getTabState(state, 'Notifications') !== TabState.Outside,
       isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 1725fdfb4..4f7054cb3 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -86,6 +86,7 @@ export type CommonNavigatorParams = {
   }
   StarterPackEdit: {rkey?: string}
   VideoFeed: VideoFeedSourceContext
+  Bookmarks: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 79d7702b3..1ba1cd3b5 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -238,6 +238,14 @@ export type MetricEvents = {
   'post:unmute': {}
   'post:pin': {}
   'post:unpin': {}
+  'post:bookmark': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+  }
+  'post:unbookmark': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
+  }
+  'bookmarks:view': {}
+  'bookmarks:post-clicked': {}
   'profile:follow': {
     didBecomeMutual: boolean | undefined
     followeeClout: number | undefined
diff --git a/src/routes.ts b/src/routes.ts
index 7fc673e2b..1ed913bb2 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -90,4 +90,5 @@ export const router = new Router<AllNavigatableRoutes>({
   StarterPackShort: '/starter-pack-short/:code',
   StarterPackWizard: '/starter-pack/create',
   VideoFeed: '/video-feed',
+  Bookmarks: '/saved',
 })
diff --git a/src/screens/Bookmarks/components/EmptyState.tsx b/src/screens/Bookmarks/components/EmptyState.tsx
new file mode 100644
index 000000000..bfd80903d
--- /dev/null
+++ b/src/screens/Bookmarks/components/EmptyState.tsx
@@ -0,0 +1,59 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {ButtonText} from '#/components/Button'
+import {BookmarkDeleteLarge} from '#/components/icons/Bookmark'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function EmptyState() {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <View
+      style={[
+        a.align_center,
+        {
+          paddingVertical: 64,
+        },
+      ]}>
+      <BookmarkDeleteLarge
+        width={64}
+        fill={t.atoms.text_contrast_medium.color}
+      />
+      <View style={[a.pt_sm]}>
+        <Text
+          style={[
+            a.text_lg,
+            a.font_medium,
+            a.text_center,
+            t.atoms.text_contrast_medium,
+          ]}>
+          <Trans>Nothing saved yet</Trans>
+        </Text>
+      </View>
+      <View style={[a.pt_2xl]}>
+        <Link
+          to="/"
+          action="navigate"
+          label={_(
+            msg({
+              message: `Go home`,
+              context: `Button to go back to the home timeline`,
+            }),
+          )}
+          size="small"
+          color="secondary">
+          <ButtonText>
+            <Trans context="Button to go back to the home timeline">
+              Go home
+            </Trans>
+          </ButtonText>
+        </Link>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Bookmarks/index.tsx b/src/screens/Bookmarks/index.tsx
new file mode 100644
index 000000000..72ad1f167
--- /dev/null
+++ b/src/screens/Bookmarks/index.tsx
@@ -0,0 +1,294 @@
+import {useCallback, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {
+  type $Typed,
+  type AppBskyBookmarkDefs,
+  AppBskyFeedDefs,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {useCleanError} from '#/lib/hooks/useCleanError'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {logger} from '#/logger'
+import {isIOS} from '#/platform/detection'
+import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
+import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {Post} from '#/view/com/post/Post'
+import {List} from '#/view/com/util/List'
+import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {EmptyState} from '#/screens/Bookmarks/components/EmptyState'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {BookmarkFilled} from '#/components/icons/Bookmark'
+import {CircleQuestion_Stroke2_Corner2_Rounded as QuestionIcon} from '#/components/icons/CircleQuestion'
+import * as Layout from '#/components/Layout'
+import {ListFooter} from '#/components/Lists'
+import * as Skele from '#/components/Skeleton'
+import * as toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'>
+
+export function BookmarksScreen({}: Props) {
+  const setMinimalShellMode = useSetMinimalShellMode()
+
+  useFocusEffect(
+    useCallback(() => {
+      setMinimalShellMode(false)
+      logger.metric('bookmarks:view', {})
+    }, [setMinimalShellMode]),
+  )
+
+  return (
+    <Layout.Screen testID="bookmarksScreen">
+      <Layout.Header.Outer>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>
+            <Trans>Saved Posts</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot />
+      </Layout.Header.Outer>
+      <BookmarksInner />
+    </Layout.Screen>
+  )
+}
+
+type ListItem =
+  | {
+      type: 'loading'
+      key: 'loading'
+    }
+  | {
+      type: 'empty'
+      key: 'empty'
+    }
+  | {
+      type: 'bookmark'
+      key: string
+      bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & {
+        item: $Typed<AppBskyFeedDefs.PostView>
+      }
+    }
+  | {
+      type: 'bookmarkNotFound'
+      key: string
+      bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & {
+        item: $Typed<AppBskyFeedDefs.NotFoundPost>
+      }
+    }
+
+function BookmarksInner() {
+  const initialNumToRender = useInitialNumToRender()
+  const cleanError = useCleanError()
+  const [isPTRing, setIsPTRing] = useState(false)
+  const {
+    data,
+    isLoading,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    error,
+    refetch,
+  } = useBookmarksQuery()
+  const cleanedError = useMemo(() => {
+    const {raw, clean} = cleanError(error)
+    return clean || raw
+  }, [error, cleanError])
+
+  const onRefresh = useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } finally {
+      setIsPTRing(false)
+    }
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = useCallback(async () => {
+    if (isFetchingNextPage || !hasNextPage || error) return
+    try {
+      await fetchNextPage()
+    } catch {}
+  }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
+
+  const items = useMemo(() => {
+    const i: ListItem[] = []
+
+    if (isLoading) {
+      i.push({type: 'loading', key: 'loading'})
+    } else if (error || !data) {
+      // handled in Footer
+    } else {
+      const bookmarks = data.pages.flatMap(p => p.bookmarks)
+
+      if (bookmarks.length > 0) {
+        for (const bookmark of bookmarks) {
+          if (AppBskyFeedDefs.isNotFoundPost(bookmark.item)) {
+            i.push({
+              type: 'bookmarkNotFound',
+              key: bookmark.item.uri,
+              bookmark: {
+                ...bookmark,
+                item: bookmark.item as $Typed<AppBskyFeedDefs.NotFoundPost>,
+              },
+            })
+          }
+          if (AppBskyFeedDefs.isPostView(bookmark.item)) {
+            i.push({
+              type: 'bookmark',
+              key: bookmark.item.uri,
+              bookmark: {
+                ...bookmark,
+                item: bookmark.item as $Typed<AppBskyFeedDefs.PostView>,
+              },
+            })
+          }
+        }
+      } else {
+        i.push({type: 'empty', key: 'empty'})
+      }
+    }
+
+    return i
+  }, [isLoading, error, data])
+
+  const isEmpty = items.length === 1 && items[0]?.type === 'empty'
+
+  return (
+    <List
+      data={items}
+      renderItem={renderItem}
+      keyExtractor={keyExtractor}
+      refreshing={isPTRing}
+      onRefresh={onRefresh}
+      onEndReached={onEndReached}
+      onEndReachedThreshold={4}
+      ListFooterComponent={
+        <ListFooter
+          isFetchingNextPage={isFetchingNextPage}
+          error={cleanedError}
+          onRetry={fetchNextPage}
+          style={[isEmpty && a.border_t_0]}
+        />
+      }
+      initialNumToRender={initialNumToRender}
+      windowSize={9}
+      maxToRenderPerBatch={isIOS ? 5 : 1}
+      updateCellsBatchingPeriod={40}
+      sideBorders={false}
+    />
+  )
+}
+
+function BookmarkNotFound({
+  hideTopBorder,
+  post,
+}: {
+  hideTopBorder: boolean
+  post: $Typed<AppBskyFeedDefs.NotFoundPost>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {mutateAsync: bookmark} = useBookmarkMutation()
+  const cleanError = useCleanError()
+
+  const remove = async () => {
+    try {
+      await bookmark({action: 'delete', uri: post.uri})
+      toast.show(_(msg`Removed from saved posts`), {
+        type: 'info',
+      })
+    } catch (e: any) {
+      const {raw, clean} = cleanError(e)
+      toast.show(clean || raw || e, {
+        type: 'error',
+      })
+    }
+  }
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_start,
+        a.px_xl,
+        a.py_lg,
+        a.gap_sm,
+        !hideTopBorder && a.border_t,
+        t.atoms.border_contrast_low,
+      ]}>
+      <Skele.Circle size={42}>
+        <QuestionIcon size="lg" fill={t.atoms.text_contrast_low.color} />
+      </Skele.Circle>
+      <View style={[a.flex_1, a.gap_2xs]}>
+        <View style={[a.flex_row, a.gap_xs]}>
+          <Skele.Text style={[a.text_md, {width: 80}]} />
+          <Skele.Text style={[a.text_md, {width: 100}]} />
+        </View>
+
+        <Text
+          style={[
+            a.text_md,
+            a.leading_snug,
+            a.italic,
+            t.atoms.text_contrast_medium,
+          ]}>
+          <Trans>This post was deleted by its author</Trans>
+        </Text>
+      </View>
+      <Button
+        label={_(msg`Remove from saved posts`)}
+        size="tiny"
+        color="secondary"
+        onPress={remove}>
+        <ButtonIcon icon={BookmarkFilled} />
+        <ButtonText>
+          <Trans>Remove</Trans>
+        </ButtonText>
+      </Button>
+    </View>
+  )
+}
+
+function renderItem({item, index}: {item: ListItem; index: number}) {
+  switch (item.type) {
+    case 'loading': {
+      return <PostFeedLoadingPlaceholder />
+    }
+    case 'empty': {
+      return <EmptyState />
+    }
+    case 'bookmark': {
+      return (
+        <Post
+          post={item.bookmark.item}
+          hideTopBorder={index === 0}
+          onBeforePress={() => {
+            logger.metric('bookmarks:post-clicked', {})
+          }}
+        />
+      )
+    }
+    case 'bookmarkNotFound': {
+      return (
+        <BookmarkNotFound
+          post={item.bookmark.item}
+          hideTopBorder={index === 0}
+        />
+      )
+    }
+    default:
+      return null
+  }
+}
+
+const keyExtractor = (item: ListItem) => item.key
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
index b59397b0b..08dd272f7 100644
--- a/src/screens/PostThread/components/ThreadItemAnchor.tsx
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -32,7 +32,6 @@ import {useSession} from '#/state/session'
 import {type OnPostSuccessData} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {type PostSource} from '#/state/unstable-post-source'
-import {formatCount} from '#/view/com/util/numeric/format'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
 import {
@@ -53,6 +52,7 @@ import {PostAlerts} from '#/components/moderation/PostAlerts'
 import {type AppModerationCause} from '#/components/Pills'
 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
 import {PostControls} from '#/components/PostControls'
+import {formatPostStatCount} from '#/components/PostControls/util'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import * as Prompt from '#/components/Prompt'
 import {RichText} from '#/components/RichText'
@@ -415,13 +415,18 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
           />
           {post.repostCount !== 0 ||
           post.likeCount !== 0 ||
-          post.quoteCount !== 0 ? (
+          post.quoteCount !== 0 ||
+          post.bookmarkCount !== 0 ? (
             // Show this section unless we're *sure* it has no engagement.
             <View
               style={[
                 a.flex_row,
+                a.flex_wrap,
                 a.align_center,
-                a.gap_lg,
+                {
+                  rowGap: a.gap_sm.gap,
+                  columnGap: a.gap_lg.gap,
+                },
                 a.border_t,
                 a.border_b,
                 a.mt_md,
@@ -434,7 +439,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
                     testID="repostCount-expanded"
                     style={[a.text_md, t.atoms.text_contrast_medium]}>
                     <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                      {formatCount(i18n, post.repostCount)}
+                      {formatPostStatCount(i18n, post.repostCount)}
                     </Text>{' '}
                     <Plural
                       value={post.repostCount}
@@ -452,7 +457,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
                     testID="quoteCount-expanded"
                     style={[a.text_md, t.atoms.text_contrast_medium]}>
                     <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                      {formatCount(i18n, post.quoteCount)}
+                      {formatPostStatCount(i18n, post.quoteCount)}
                     </Text>{' '}
                     <Plural
                       value={post.quoteCount}
@@ -468,12 +473,28 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
                     testID="likeCount-expanded"
                     style={[a.text_md, t.atoms.text_contrast_medium]}>
                     <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                      {formatCount(i18n, post.likeCount)}
+                      {formatPostStatCount(i18n, post.likeCount)}
                     </Text>{' '}
                     <Plural value={post.likeCount} one="like" other="likes" />
                   </Text>
                 </Link>
               ) : null}
+              {post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
+                <Link to={likesHref} label={_(msg`Saves of this post`)}>
+                  <Text
+                    testID="bookmarkCount-expanded"
+                    style={[a.text_md, t.atoms.text_contrast_medium]}>
+                    <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
+                      {formatPostStatCount(i18n, post.bookmarkCount)}
+                    </Text>{' '}
+                    <Plural
+                      value={post.bookmarkCount}
+                      one="save"
+                      other="saves"
+                    />
+                  </Text>
+                </Link>
+              ) : null}
             </View>
           ) : null}
           <View
diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx
index b009695a9..5d3123358 100644
--- a/src/screens/PostThread/components/ThreadItemTreePost.tsx
+++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx
@@ -368,6 +368,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
                     </View>
                   )}
                   <PostControls
+                    variant="compact"
                     post={postShadow}
                     record={record}
                     richText={richText}
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index 8cc3dca1a..312bc6df8 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -25,6 +25,7 @@ export interface PostShadow {
   embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
   pinned: boolean
   optimisticReplyCount: number | undefined
+  bookmarked: boolean | undefined
 }
 
 export const POST_TOMBSTONE = Symbol('PostTombstone')
@@ -92,6 +93,18 @@ function mergeShadow(
     likeCount = Math.max(0, likeCount)
   }
 
+  let bookmarkCount = post.bookmarkCount ?? 0
+  if ('bookmarked' in shadow) {
+    const wasBookmarked = !!post.viewer?.bookmarked
+    const isBookmarked = !!shadow.bookmarked
+    if (wasBookmarked && !isBookmarked) {
+      bookmarkCount--
+    } else if (!wasBookmarked && isBookmarked) {
+      bookmarkCount++
+    }
+    bookmarkCount = Math.max(0, bookmarkCount)
+  }
+
   let repostCount = post.repostCount ?? 0
   if ('repostUri' in shadow) {
     const wasReposted = !!post.viewer?.repost
@@ -127,11 +140,14 @@ function mergeShadow(
     likeCount: likeCount,
     repostCount: repostCount,
     replyCount: replyCount,
+    bookmarkCount: bookmarkCount,
     viewer: {
       ...(post.viewer || {}),
       like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
       repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
       pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned,
+      bookmarked:
+        'bookmarked' in shadow ? shadow.bookmarked : post.viewer?.bookmarked,
     },
   })
 }
diff --git a/src/state/queries/bookmarks/useBookmarkMutation.ts b/src/state/queries/bookmarks/useBookmarkMutation.ts
new file mode 100644
index 000000000..c6e745aa0
--- /dev/null
+++ b/src/state/queries/bookmarks/useBookmarkMutation.ts
@@ -0,0 +1,65 @@
+import {type AppBskyFeedDefs} from '@atproto/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {isNetworkError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {updatePostShadow} from '#/state/cache/post-shadow'
+import {
+  optimisticallyDeleteBookmark,
+  optimisticallySaveBookmark,
+} from '#/state/queries/bookmarks/useBookmarksQuery'
+import {useAgent} from '#/state/session'
+
+type MutationArgs =
+  | {action: 'create'; post: AppBskyFeedDefs.PostView}
+  | {
+      action: 'delete'
+      /**
+       * For deletions, we only need to URI. Plus, in some cases we only know the
+       * URI, such as when a post was deleted by the author.
+       */
+      uri: string
+    }
+
+export function useBookmarkMutation() {
+  const qc = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation({
+    async mutationFn(args: MutationArgs) {
+      if (args.action === 'create') {
+        updatePostShadow(qc, args.post.uri, {bookmarked: true})
+        await agent.app.bsky.bookmark.createBookmark({
+          uri: args.post.uri,
+          cid: args.post.cid,
+        })
+      } else if (args.action === 'delete') {
+        updatePostShadow(qc, args.uri, {bookmarked: false})
+        await agent.app.bsky.bookmark.deleteBookmark({
+          uri: args.uri,
+        })
+      }
+    },
+    onSuccess(_, args) {
+      if (args.action === 'create') {
+        optimisticallySaveBookmark(qc, args.post)
+      } else if (args.action === 'delete') {
+        optimisticallyDeleteBookmark(qc, {uri: args.uri})
+      }
+    },
+    onError(e, args) {
+      if (args.action === 'create') {
+        updatePostShadow(qc, args.post.uri, {bookmarked: false})
+      } else if (args.action === 'delete') {
+        updatePostShadow(qc, args.uri, {bookmarked: true})
+      }
+
+      if (!isNetworkError(e)) {
+        logger.error('bookmark mutation failed', {
+          bookmarkAction: args.action,
+          safeMessage: e,
+        })
+      }
+    },
+  })
+}
diff --git a/src/state/queries/bookmarks/useBookmarksQuery.ts b/src/state/queries/bookmarks/useBookmarksQuery.ts
new file mode 100644
index 000000000..46838facb
--- /dev/null
+++ b/src/state/queries/bookmarks/useBookmarksQuery.ts
@@ -0,0 +1,114 @@
+import {
+  type $Typed,
+  type AppBskyBookmarkGetBookmarks,
+  type AppBskyFeedDefs,
+} from '@atproto/api'
+import {
+  type InfiniteData,
+  type QueryClient,
+  type QueryKey,
+  useInfiniteQuery,
+} from '@tanstack/react-query'
+
+import {useAgent} from '#/state/session'
+
+export const bookmarksQueryKeyRoot = 'bookmarks'
+export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot]
+
+export function useBookmarksQuery() {
+  const agent = useAgent()
+
+  return useInfiniteQuery<
+    AppBskyBookmarkGetBookmarks.OutputSchema,
+    Error,
+    InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    queryKey: createBookmarksQueryKey(),
+    async queryFn({pageParam}) {
+      const res = await agent.app.bsky.bookmark.getBookmarks({
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
+
+export async function truncateAndInvalidate(qc: QueryClient) {
+  qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
+    {queryKey: [bookmarksQueryKeyRoot]},
+    data => {
+      if (data) {
+        return {
+          pageParams: data.pageParams.slice(0, 1),
+          pages: data.pages.slice(0, 1),
+        }
+      }
+      return data
+    },
+  )
+  return qc.invalidateQueries({queryKey: [bookmarksQueryKeyRoot]})
+}
+
+export async function optimisticallySaveBookmark(
+  qc: QueryClient,
+  post: AppBskyFeedDefs.PostView,
+) {
+  qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
+    {
+      queryKey: [bookmarksQueryKeyRoot],
+    },
+    data => {
+      if (!data) return data
+      return {
+        ...data,
+        pages: data.pages.map((page, index) => {
+          if (index === 0) {
+            post.$type = 'app.bsky.feed.defs#postView'
+            return {
+              ...page,
+              bookmarks: [
+                {
+                  createdAt: new Date().toISOString(),
+                  subject: {
+                    uri: post.uri,
+                    cid: post.cid,
+                  },
+                  item: post as $Typed<AppBskyFeedDefs.PostView>,
+                },
+                ...page.bookmarks,
+              ],
+            }
+          }
+          return page
+        }),
+      }
+    },
+  )
+}
+
+export async function optimisticallyDeleteBookmark(
+  qc: QueryClient,
+  {uri}: {uri: string},
+) {
+  qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
+    {
+      queryKey: [bookmarksQueryKeyRoot],
+    },
+    data => {
+      if (!data) return data
+      return {
+        ...data,
+        pages: data.pages.map(page => {
+          return {
+            ...page,
+            bookmarks: page.bookmarks.filter(b => b.subject.uri !== uri),
+          }
+        }),
+      }
+    },
+  )
+}
diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts
index 7577d6b20..165649447 100644
--- a/src/state/queries/nuxs/definitions.ts
+++ b/src/state/queries/nuxs/definitions.ts
@@ -9,6 +9,7 @@ export enum Nux {
   ActivitySubscriptions = 'ActivitySubscriptions',
   AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
   AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
+  BookmarksAnnouncement = 'BookmarksAnnouncement',
 
   /*
    * Blocking announcements. New IDs are required for each new announcement.
@@ -47,6 +48,10 @@ export type AppNux = BaseNux<
       id: Nux.PolicyUpdate202508
       data: undefined
     }
+  | {
+      id: Nux.BookmarksAnnouncement
+      data: undefined
+    }
 >
 
 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -57,4 +62,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
   [Nux.AgeAssuranceDismissibleNotice]: undefined,
   [Nux.AgeAssuranceDismissibleFeedBanner]: undefined,
   [Nux.PolicyUpdate202508]: undefined,
+  [Nux.BookmarksAnnouncement]: undefined,
 }
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index f41f48e40..58cb40f71 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -43,11 +43,13 @@ export function Post({
   showReplyLine,
   hideTopBorder,
   style,
+  onBeforePress,
 }: {
   post: AppBskyFeedDefs.PostView
   showReplyLine?: boolean
   hideTopBorder?: boolean
   style?: StyleProp<ViewStyle>
+  onBeforePress?: () => void
 }) {
   const moderationOpts = useModerationOpts()
   const record = useMemo<AppBskyFeedPost.Record | undefined>(
@@ -85,6 +87,7 @@ export function Post({
         showReplyLine={showReplyLine}
         hideTopBorder={hideTopBorder}
         style={style}
+        onBeforePress={onBeforePress}
       />
     )
   }
@@ -99,6 +102,7 @@ function PostInner({
   showReplyLine,
   hideTopBorder,
   style,
+  onBeforePress: outerOnBeforePress,
 }: {
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
@@ -107,6 +111,7 @@ function PostInner({
   showReplyLine?: boolean
   hideTopBorder?: boolean
   style?: StyleProp<ViewStyle>
+  onBeforePress?: () => void
 }) {
   const queryClient = useQueryClient()
   const pal = usePalette('default')
@@ -142,7 +147,8 @@ function PostInner({
 
   const onBeforePress = useCallback(() => {
     unstableCacheProfileView(queryClient, post.author)
-  }, [queryClient, post.author])
+    outerOnBeforePress?.()
+  }, [queryClient, post.author, outerOnBeforePress])
 
   const [hover, setHover] = useState(false)
   return (
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 79d8a21ae..10817407f 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -30,6 +30,7 @@ import {
   Bell_Filled_Corner0_Rounded as BellFilled,
   Bell_Stroke2_Corner0_Rounded as Bell,
 } from '#/components/icons/Bell'
+import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
 import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList'
 import {
   Hashtag_Filled_Corner0_Rounded as HashtagFilled,
@@ -150,6 +151,7 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
     isAtHome,
     isAtSearch,
     isAtFeeds,
+    isAtBookmarks,
     isAtNotifications,
     isAtMyProfile,
     isAtMessages,
@@ -231,6 +233,11 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
     setDrawerOpen(false)
   }, [navigation, setDrawerOpen])
 
+  const onPressBookmarks = React.useCallback(() => {
+    navigation.navigate('Bookmarks')
+    setDrawerOpen(false)
+  }, [navigation, setDrawerOpen])
+
   const onPressSettings = React.useCallback(() => {
     navigation.navigate('Settings')
     setDrawerOpen(false)
@@ -292,6 +299,10 @@ let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => {
             />
             <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} />
             <ListsMenuItem onPress={onPressLists} />
+            <BookmarksMenuItem
+              isActive={isAtBookmarks}
+              onPress={onPressBookmarks}
+            />
             <ProfileMenuItem
               isActive={isAtMyProfile}
               onPress={onPressProfile}
@@ -538,6 +549,32 @@ let ListsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
 }
 ListsMenuItem = React.memo(ListsMenuItem)
 
+let BookmarksMenuItem = ({
+  isActive,
+  onPress,
+}: {
+  isActive: boolean
+  onPress: () => void
+}): React.ReactNode => {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  return (
+    <MenuItem
+      icon={
+        isActive ? (
+          <BookmarkFilled style={[t.atoms.text]} width={iconWidth} />
+        ) : (
+          <Bookmark style={[t.atoms.text]} width={iconWidth} />
+        )
+      }
+      label={_(msg`Saved`)}
+      onPress={onPress}
+    />
+  )
+}
+BookmarksMenuItem = React.memo(BookmarksMenuItem)
+
 let ProfileMenuItem = ({
   isActive,
   onPress,
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index cf1ff8425..c1e429c73 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -40,6 +40,7 @@ import {
   Bell_Filled_Corner0_Rounded as BellFilled,
   Bell_Stroke2_Corner0_Rounded as Bell,
 } from '#/components/icons/Bell'
+import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
 import {
   BulletList_Filled_Corner0_Rounded as ListFilled,
   BulletList_Stroke2_Corner0_Rounded as List,
@@ -744,6 +745,24 @@ export function DesktopLeftNav() {
             label={_(msg`Lists`)}
           />
           <NavItem
+            href="/saved"
+            icon={
+              <Bookmark
+                style={pal.text}
+                aria-hidden={true}
+                width={NAV_ICON_WIDTH}
+              />
+            }
+            iconFilled={
+              <BookmarkFilled
+                style={pal.text}
+                aria-hidden={true}
+                width={NAV_ICON_WIDTH}
+              />
+            }
+            label={_(msg`Saved`)}
+          />
+          <NavItem
             href={currentAccount ? makeProfileLink(currentAccount) : '/'}
             icon={
               <UserCircle