From 535d4d6cf74cfb49a70804bccb4de1613d2ac09c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Sep 2025 17:30:15 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=93=20Bookmarks=20(#8976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add button to controls, respace * Hook up shadow and mutation * Add Bookmarks screen * Build out Bookmarks screen * Handle removals via shadow * Use truncateAndInvalidate strategy * Add empty state * Add toasts * Add undo buttons to toasts * Stage NUX, needs image * Finesse post controls * New reply icon * Use curvier variant of repost icon * Prevent layout shift with align_start * Update api pkg * Swap in new image * Limit spacing on desktop * Rm decimals over 10k * Better optimistic adding/removing * Add metrics * Comment * Remove unused code block * Remove debug limit * Fork shadow for web/native * Tweak alt * add preventExpansion: true * Refine hitslop * Add count to anchor * Reduce space in compact mode --------- Co-authored-by: Samuel Newman --- assets/icons/bookmark.svg | 1 + assets/icons/bookmarkDeleteLarge.svg | 1 + assets/icons/bookmarkFilled.svg | 1 + assets/icons/reply.svg | 1 + assets/icons/replyFiled.svg | 1 + assets/images/bookmarks_announcement_nux.webp | Bin 0 -> 12656 bytes bskyweb/cmd/bskyweb/server.go | 3 + package.json | 2 +- src/Navigation.tsx | 9 + .../PostControls/BookmarkButton.tsx | 136 ++++++++ .../PostControls/PostControlButton.tsx | 27 +- .../PostControls/PostMenu/index.tsx | 6 +- src/components/PostControls/RepostButton.tsx | 8 +- .../PostControls/ShareMenu/index.tsx | 6 +- src/components/PostControls/index.tsx | 224 +++++++------ src/components/PostControls/util.ts | 24 ++ .../dialogs/nuxs/BookmarksAnnouncement.tsx | 177 +++++++++++ src/components/dialogs/nuxs/index.tsx | 13 +- src/components/icons/Bookmark.tsx | 16 + src/components/icons/Reply.tsx | 11 + src/lib/constants.ts | 1 - src/lib/custom-animations/CountWheel.tsx | 12 +- src/lib/hooks/useNavigationTabState.ts | 1 + src/lib/routes/types.ts | 1 + src/logger/metrics.ts | 8 + src/routes.ts | 1 + .../Bookmarks/components/EmptyState.tsx | 59 ++++ src/screens/Bookmarks/index.tsx | 294 ++++++++++++++++++ .../components/ThreadItemAnchor.tsx | 33 +- .../components/ThreadItemTreePost.tsx | 1 + src/state/cache/post-shadow.ts | 16 + .../queries/bookmarks/useBookmarkMutation.ts | 65 ++++ .../queries/bookmarks/useBookmarksQuery.ts | 114 +++++++ src/state/queries/nuxs/definitions.ts | 6 + src/view/com/post/Post.tsx | 8 +- src/view/shell/Drawer.tsx | 37 +++ src/view/shell/desktop/LeftNav.tsx | 19 ++ yarn.lock | 71 +++-- 38 files changed, 1251 insertions(+), 163 deletions(-) create mode 100644 assets/icons/bookmark.svg create mode 100644 assets/icons/bookmarkDeleteLarge.svg create mode 100644 assets/icons/bookmarkFilled.svg create mode 100644 assets/icons/reply.svg create mode 100644 assets/icons/replyFiled.svg create mode 100644 assets/images/bookmarks_announcement_nux.webp create mode 100644 src/components/PostControls/BookmarkButton.tsx create mode 100644 src/components/PostControls/util.ts create mode 100644 src/components/dialogs/nuxs/BookmarksAnnouncement.tsx create mode 100644 src/components/icons/Bookmark.tsx create mode 100644 src/components/icons/Reply.tsx create mode 100644 src/screens/Bookmarks/components/EmptyState.tsx create mode 100644 src/screens/Bookmarks/index.tsx create mode 100644 src/state/queries/bookmarks/useBookmarkMutation.ts create mode 100644 src/state/queries/bookmarks/useBookmarksQuery.ts diff --git a/assets/icons/bookmark.svg b/assets/icons/bookmark.svg new file mode 100644 index 0000000000..24348ac497 --- /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 0000000000..e2ded1e8d8 --- /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 0000000000..8a57e9f3d0 --- /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 0000000000..7de9e7174a --- /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 0000000000..c4518e8020 --- /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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 e + 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 0000000000..70acebc053 --- /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 ae69b1322d..f7070c4c8c 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 63aa460fbd..1102aa9a44 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 e09950b49c..522e80dd3f 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 d4ea18bb0c..6f59c0d422 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 16330a6826..834ad8e7dc 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', + })} + + )} + + + + - - - - - - + + 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 0000000000..5d3ea74e49 --- /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 0000000000..c635583345 --- /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 985d58eecc..bb15c5f630 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 0000000000..c8fb8242f4 --- /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 0000000000..0317dd2f34 --- /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 b6b06ee7f0..5871821f4b 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 4b131db2dc..6db22554e1 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 2d15bce562..7fd76cb1b9 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 1725fdfb4c..4f7054cb3f 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 79d7702b33..1ba1cd3b50 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 7fc673e2be..1ed913bb29 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 0000000000..bfd80903d1 --- /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 0000000000..72ad1f1677 --- /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 b59397b0b5..08dd272f76 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 7577d6b205..1656494471 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 f41f48e40c..58cb40f711 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 79d8a21aef..10817407f2 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 cf1ff84251..c1e429c738 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`)} + />