diff --git a/assets/icons/bookmark.svg b/assets/icons/bookmark.svg
new file mode 100644
index 000000000..24348ac49
--- /dev/null
+++ b/assets/icons/bookmark.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/bookmarkDeleteLarge.svg b/assets/icons/bookmarkDeleteLarge.svg
new file mode 100644
index 000000000..e2ded1e8d
--- /dev/null
+++ b/assets/icons/bookmarkDeleteLarge.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/bookmarkFilled.svg b/assets/icons/bookmarkFilled.svg
new file mode 100644
index 000000000..8a57e9f3d
--- /dev/null
+++ b/assets/icons/bookmarkFilled.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/reply.svg b/assets/icons/reply.svg
new file mode 100644
index 000000000..7de9e7174
--- /dev/null
+++ b/assets/icons/reply.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/replyFiled.svg b/assets/icons/replyFiled.svg
new file mode 100644
index 000000000..c4518e802
--- /dev/null
+++ b/assets/icons/replyFiled.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/bookmarks_announcement_nux.webp b/assets/images/bookmarks_announcement_nux.webp
new file mode 100644
index 0000000000000000000000000000000000000000..0e2d9cfb2e55b227334465660b30fb83e24c6d5d
GIT binary patch
literal 12656
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
+ 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
+ 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(
+
+
+
+ Post saved
+
+ {!disableUndo && (
+ remove({disableUndo: true})}>
+ {undoLabel}
+
+ )}
+ ,
+ {
+ 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(
+
+
+
+ Removed from saved posts
+
+ {!disableUndo && (
+ save({disableUndo: true})}>
+ {undoLabel}
+
+ )}
+ ,
+ )
+ } 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 (
+
+
+
+ )
+})
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 & {
ref?: React.Ref
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 => (
@@ -102,12 +107,20 @@ export function PostControlButton({
export function PostControlButtonIcon({
icon: Comp,
-}: {
+ style,
+ ...rest
+}: SVGIconProps & {
icon: React.ComponentType
}) {
const {big, color} = useContext(PostControlContext)
- return
+ return (
+
+ )
}
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
@@ -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}>
)
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 = ({
{typeof repostCount !== 'undefined' && repostCount > 0 && (
- {formatCount(i18n, repostCount)}
+ {formatPostStatCount(i18n, repostCount, {compact: compactCount})}
)}
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
@@ -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}>
)
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
@@ -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 (
-
- 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}>
-
- {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
-
- {formatCount(i18n, post.replyCount)}
-
- )}
-
-
-
-
-
-
- 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',
- }),
- )
- }>
-
-
+
+ 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}>
+
+ {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
+
+ {formatPostStatCount(i18n, post.replyCount, {
+ compact: variant === 'compact',
+ })}
+
+ )}
+
+
+
+
-
-
-
-
-
+
+
+ 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',
+ }),
+ )
+ }>
+
+
+
+ {/* Spacer! */}
+
-
+
+
+
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 (
+
+
+
+
+
+
+
+
+
+ New Feature
+
+
+
+
+
+
+
+
+
+
+
+
+ Saved Posts
+
+
+
+ Finally! Keep track of posts that matter to you. Save them to
+ revisit anytime.
+
+
+
+
+ {!isWeb && (
+
+ )}
+
+
+
+
+
+ )
+}
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 (
{/*For example, activeNux === Nux.NeueTypography && */}
- {activeNux === Nux.ActivitySubscriptions && }
+ {activeNux === Nux.BookmarksAnnouncement && }
)
}
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({
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 (
+
+
+
+
+ Nothing saved yet
+
+
+
+
+
+
+ Go home
+
+
+
+
+
+ )
+}
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
+
+export function BookmarksScreen({}: Props) {
+ const setMinimalShellMode = useSetMinimalShellMode()
+
+ useFocusEffect(
+ useCallback(() => {
+ setMinimalShellMode(false)
+ logger.metric('bookmarks:view', {})
+ }, [setMinimalShellMode]),
+ )
+
+ return (
+
+
+
+
+
+ Saved Posts
+
+
+
+
+
+
+ )
+}
+
+type ListItem =
+ | {
+ type: 'loading'
+ key: 'loading'
+ }
+ | {
+ type: 'empty'
+ key: 'empty'
+ }
+ | {
+ type: 'bookmark'
+ key: string
+ bookmark: Omit & {
+ item: $Typed
+ }
+ }
+ | {
+ type: 'bookmarkNotFound'
+ key: string
+ bookmark: Omit & {
+ item: $Typed
+ }
+ }
+
+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,
+ },
+ })
+ }
+ if (AppBskyFeedDefs.isPostView(bookmark.item)) {
+ i.push({
+ type: 'bookmark',
+ key: bookmark.item.uri,
+ bookmark: {
+ ...bookmark,
+ item: bookmark.item as $Typed,
+ },
+ })
+ }
+ }
+ } else {
+ i.push({type: 'empty', key: 'empty'})
+ }
+ }
+
+ return i
+ }, [isLoading, error, data])
+
+ const isEmpty = items.length === 1 && items[0]?.type === 'empty'
+
+ return (
+
+ }
+ initialNumToRender={initialNumToRender}
+ windowSize={9}
+ maxToRenderPerBatch={isIOS ? 5 : 1}
+ updateCellsBatchingPeriod={40}
+ sideBorders={false}
+ />
+ )
+}
+
+function BookmarkNotFound({
+ hideTopBorder,
+ post,
+}: {
+ hideTopBorder: boolean
+ post: $Typed
+}) {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+ This post was deleted by its author
+
+
+
+
+ )
+}
+
+function renderItem({item, index}: {item: ListItem; index: number}) {
+ switch (item.type) {
+ case 'loading': {
+ return
+ }
+ case 'empty': {
+ return
+ }
+ case 'bookmark': {
+ return (
+ {
+ logger.metric('bookmarks:post-clicked', {})
+ }}
+ />
+ )
+ }
+ case 'bookmarkNotFound': {
+ return (
+
+ )
+ }
+ 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.
- {formatCount(i18n, post.repostCount)}
+ {formatPostStatCount(i18n, post.repostCount)}
{' '}
- {formatCount(i18n, post.quoteCount)}
+ {formatPostStatCount(i18n, post.quoteCount)}
{' '}
- {formatCount(i18n, post.likeCount)}
+ {formatPostStatCount(i18n, post.likeCount)}
{' '}
) : null}
+ {post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
+
+
+
+ {formatPostStatCount(i18n, post.bookmarkCount)}
+ {' '}
+
+
+
+ ) : null}
) : null}
)}
[bookmarksQueryKeyRoot]
+
+export function useBookmarksQuery() {
+ const agent = useAgent()
+
+ return useInfiniteQuery<
+ AppBskyBookmarkGetBookmarks.OutputSchema,
+ Error,
+ InfiniteData,
+ 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>(
+ {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>(
+ {
+ 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,
+ },
+ ...page.bookmarks,
+ ],
+ }
+ }
+ return page
+ }),
+ }
+ },
+ )
+}
+
+export async function optimisticallyDeleteBookmark(
+ qc: QueryClient,
+ {uri}: {uri: string},
+) {
+ qc.setQueriesData>(
+ {
+ 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 | undefined> = {
@@ -57,4 +62,5 @@ export const NuxSchemas: Record | 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
+ onBeforePress?: () => void
}) {
const moderationOpts = useModerationOpts()
const record = useMemo(
@@ -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
record: AppBskyFeedPost.Record
@@ -107,6 +111,7 @@ function PostInner({
showReplyLine?: boolean
hideTopBorder?: boolean
style?: StyleProp
+ 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 => {
/>
+
void}): React.ReactNode => {
}
ListsMenuItem = React.memo(ListsMenuItem)
+let BookmarksMenuItem = ({
+ isActive,
+ onPress,
+}: {
+ isActive: boolean
+ onPress: () => void
+}): React.ReactNode => {
+ const {_} = useLingui()
+ const t = useTheme()
+
+ return (
+
+ ) : (
+
+ )
+ }
+ 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,
@@ -743,6 +744,24 @@ export function DesktopLeftNav() {
}
label={_(msg`Lists`)}
/>
+
+ }
+ iconFilled={
+
+ }
+ label={_(msg`Saved`)}
+ />