diff options
author | Eric Bailey <git@esb.lol> | 2025-09-04 17:30:15 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-09-04 17:30:15 -0500 |
commit | 535d4d6cf74cfb49a70804bccb4de1613d2ac09c (patch) | |
tree | 78198de5712398e5a9a4b43ec69b254f81081442 /src | |
parent | 04b869714e512ed29653892d45dab806396824e1 (diff) | |
download | voidsky-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')
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 |