From 535d4d6cf74cfb49a70804bccb4de1613d2ac09c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 4 Sep 2025 17:30:15 -0500 Subject: 📓 Bookmarks (#8976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add button to controls, respace * Hook up shadow and mutation * Add Bookmarks screen * Build out Bookmarks screen * Handle removals via shadow * Use truncateAndInvalidate strategy * Add empty state * Add toasts * Add undo buttons to toasts * Stage NUX, needs image * Finesse post controls * New reply icon * Use curvier variant of repost icon * Prevent layout shift with align_start * Update api pkg * Swap in new image * Limit spacing on desktop * Rm decimals over 10k * Better optimistic adding/removing * Add metrics * Comment * Remove unused code block * Remove debug limit * Fork shadow for web/native * Tweak alt * add preventExpansion: true * Refine hitslop * Add count to anchor * Reduce space in compact mode --------- Co-authored-by: Samuel Newman --- src/components/PostControls/BookmarkButton.tsx | 136 +++++++++++++ src/components/PostControls/PostControlButton.tsx | 27 ++- src/components/PostControls/PostMenu/index.tsx | 6 +- src/components/PostControls/RepostButton.tsx | 8 +- src/components/PostControls/ShareMenu/index.tsx | 6 +- src/components/PostControls/index.tsx | 222 +++++++++++++--------- src/components/PostControls/util.ts | 24 +++ 7 files changed, 322 insertions(+), 107 deletions(-) create mode 100644 src/components/PostControls/BookmarkButton.tsx create mode 100644 src/components/PostControls/util.ts (limited to 'src/components/PostControls') diff --git a/src/components/PostControls/BookmarkButton.tsx b/src/components/PostControls/BookmarkButton.tsx new file mode 100644 index 000000000..70acebc05 --- /dev/null +++ b/src/components/PostControls/BookmarkButton.tsx @@ -0,0 +1,136 @@ +import {memo} from 'react' +import {type Insets} from 'react-native' +import {type AppBskyFeedDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type React from 'react' + +import {useCleanError} from '#/lib/hooks/useCleanError' +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/post-shadow' +import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' +import {useTheme} from '#/alf' +import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import * as toast from '#/components/Toast' +import {PostControlButton, PostControlButtonIcon} from './PostControlButton' + +export const BookmarkButton = memo(function BookmarkButton({ + post, + big, + logContext, + hitSlop, +}: { + post: Shadow + big?: boolean + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + hitSlop?: Insets +}): React.ReactNode { + const t = useTheme() + const {_} = useLingui() + const {mutateAsync: bookmark} = useBookmarkMutation() + const cleanError = useCleanError() + + const {viewer} = post + const isBookmarked = !!viewer?.bookmarked + + const undoLabel = _( + msg({ + message: `Undo`, + context: `Button label to undo saving/removing a post from saved posts.`, + }), + ) + + const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => { + try { + await bookmark({ + action: 'create', + post, + }) + + logger.metric('post:bookmark', {logContext}) + + toast.show( + + + + Post saved + + {!disableUndo && ( + remove({disableUndo: true})}> + {undoLabel} + + )} + , + { + type: 'success', + }, + ) + } catch (e: any) { + const {raw, clean} = cleanError(e) + toast.show(clean || raw || e, { + type: 'error', + }) + } + } + + const remove = async ({disableUndo}: {disableUndo?: boolean} = {}) => { + try { + await bookmark({ + action: 'delete', + uri: post.uri, + }) + + logger.metric('post:unbookmark', {logContext}) + + toast.show( + + + + Removed from saved posts + + {!disableUndo && ( + save({disableUndo: true})}> + {undoLabel} + + )} + , + ) + } catch (e: any) { + const {raw, clean} = cleanError(e) + toast.show(clean || raw || e, { + type: 'error', + }) + } + } + + const onHandlePress = async () => { + if (isBookmarked) { + await remove() + } else { + await save() + } + } + + return ( + + + + ) +}) diff --git a/src/components/PostControls/PostControlButton.tsx b/src/components/PostControls/PostControlButton.tsx index ae69b1322..f7070c4c8 100644 --- a/src/components/PostControls/PostControlButton.tsx +++ b/src/components/PostControls/PostControlButton.tsx @@ -1,13 +1,14 @@ import {createContext, useContext, useMemo} from 'react' -import {type GestureResponderEvent, type View} from 'react-native' +import {type GestureResponderEvent, type Insets, type View} from 'react-native' -import {POST_CTRL_HITSLOP} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {atoms as a, useTheme} from '#/alf' import {Button, type ButtonProps} from '#/components/Button' import {type Props as SVGIconProps} from '#/components/icons/common' import {Text, type TextProps} from '#/components/Typography' +export const DEFAULT_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} + const PostControlContext = createContext<{ big?: boolean active?: boolean @@ -25,12 +26,13 @@ export function PostControlButton({ active, activeColor, ...props -}: ButtonProps & { +}: Omit & { ref?: React.Ref active?: boolean big?: boolean color?: string activeColor?: string + hitSlop?: Insets }) { const t = useTheme() const playHaptic = useHaptics() @@ -83,8 +85,11 @@ export function PostControlButton({ shape="round" variant="ghost" color="secondary" - hitSlop={POST_CTRL_HITSLOP} - {...props}> + {...props} + hitSlop={{ + ...DEFAULT_HITSLOP, + ...(props.hitSlop || {}), + }}> {typeof children === 'function' ? ( args => ( @@ -102,12 +107,20 @@ export function PostControlButton({ export function PostControlButtonIcon({ icon: Comp, -}: { + style, + ...rest +}: SVGIconProps & { icon: React.ComponentType }) { const {big, color} = useContext(PostControlContext) - return + return ( + + ) } export function PostControlButtonText({style, ...props}: TextProps) { diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx index 63aa460fb..1102aa9a4 100644 --- a/src/components/PostControls/PostMenu/index.tsx +++ b/src/components/PostControls/PostMenu/index.tsx @@ -1,4 +1,5 @@ import {memo, useMemo, useState} from 'react' +import {type Insets} from 'react-native' import { type AppBskyFeedDefs, type AppBskyFeedPost, @@ -28,6 +29,7 @@ let PostMenuButton = ({ timestamp, threadgateRecord, onShowLess, + hitSlop, }: { testID: string post: Shadow @@ -39,6 +41,7 @@ let PostMenuButton = ({ timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void + hitSlop?: Insets }): React.ReactNode => { const {_} = useLingui() @@ -66,7 +69,8 @@ let PostMenuButton = ({ testID="postDropdownBtn" big={big} label={props.accessibilityLabel} - {...props}> + {...props} + hitSlop={hitSlop}> ) diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx index e09950b49..522e80dd3 100644 --- a/src/components/PostControls/RepostButton.tsx +++ b/src/components/PostControls/RepostButton.tsx @@ -5,12 +5,12 @@ import {useLingui} from '@lingui/react' import {useHaptics} from '#/lib/haptics' import {useRequireAuth} from '#/state/session' -import {formatCount} from '#/view/com/util/numeric/format' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' -import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' +import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost' +import {formatPostStatCount} from '#/components/PostControls/util' import {Text} from '#/components/Typography' import { PostControlButton, @@ -25,6 +25,7 @@ interface Props { onQuote: () => void big?: boolean embeddingDisabled: boolean + compactCount?: boolean } let RepostButton = ({ @@ -34,6 +35,7 @@ let RepostButton = ({ onQuote, big, embeddingDisabled, + compactCount, }: Props): React.ReactNode => { const t = useTheme() const {_, i18n} = useLingui() @@ -86,7 +88,7 @@ let RepostButton = ({ {typeof repostCount !== 'undefined' && repostCount > 0 && ( - {formatCount(i18n, repostCount)} + {formatPostStatCount(i18n, repostCount, {compact: compactCount})} )} diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx index d4ea18bb0..6f59c0d42 100644 --- a/src/components/PostControls/ShareMenu/index.tsx +++ b/src/components/PostControls/ShareMenu/index.tsx @@ -1,4 +1,5 @@ import {memo, useMemo, useState} from 'react' +import {type Insets} from 'react-native' import { type AppBskyFeedDefs, type AppBskyFeedPost, @@ -34,6 +35,7 @@ let ShareMenuButton = ({ timestamp, threadgateRecord, onShare, + hitSlop, }: { testID: string post: Shadow @@ -43,6 +45,7 @@ let ShareMenuButton = ({ timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record onShare: () => void + hitSlop?: Insets }): React.ReactNode => { const {_} = useLingui() const gate = useGate() @@ -92,7 +95,8 @@ let ShareMenuButton = ({ big={big} label={props.accessibilityLabel} {...props} - onLongPress={native(onNativeLongPress)}> + onLongPress={native(onNativeLongPress)} + hitSlop={hitSlop}> ) diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index 16330a682..834ad8e7d 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -24,10 +24,11 @@ import { ProgressGuideAction, useProgressGuideControls, } from '#/state/shell/progress-guide' -import {formatCount} from '#/view/com/util/numeric/format' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useBreakpoints} from '#/alf' -import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' +import {atoms as a, flatten, useBreakpoints} from '#/alf' +import {Reply as Bubble} from '#/components/icons/Reply' +import {formatPostStatCount} from '#/components/PostControls/util' +import {BookmarkButton} from './BookmarkButton' import { PostControlButton, PostControlButtonIcon, @@ -51,6 +52,7 @@ let PostControls = ({ threadgateRecord, onShowLess, viaRepost, + variant, }: { big?: boolean post: Shadow @@ -65,9 +67,9 @@ let PostControls = ({ threadgateRecord?: AppBskyFeedThreadgate.Record onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void viaRepost?: {uri: string; cid: string} + variant?: 'compact' | 'normal' | 'large' }): React.ReactNode => { const {_, i18n} = useLingui() - const {gtMobile} = useBreakpoints() const {openComposer} = useOpenComposer() const {feedDescriptor} = useFeedFeedbackContext() const [queueLike, queueUnlike] = usePostLikeMutationQueue( @@ -92,6 +94,7 @@ let PostControls = ({ post.author.viewer?.blockingByList, ) const replyDisabled = post.viewer?.replyDisabled + const {gtPhone} = useBreakpoints() const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) @@ -184,6 +187,12 @@ let PostControls = ({ }) } + const secondaryControlSpacingStyles = flatten([ + {gap: 0}, // default, we want `gap` to be defined on the resulting object + variant !== 'compact' && a.gap_xs, + (big || gtPhone) && a.gap_sm, + ]) + return ( - - requireAuth(() => onPressReply()) : undefined - } - label={_( - msg({ - message: `Reply (${plural(post.replyCount || 0, { - one: '# reply', - other: '# replies', - })})`, - comment: - 'Accessibility label for the reply button, verb form followed by number of replies and noun form', - }), - )} - big={big}> - - {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( - - {formatCount(i18n, post.replyCount)} - - )} - - - - - - - requireAuth(() => onPressToggleLike())} - label={ - post.viewer?.like - ? _( - msg({ - message: `Unlike (${plural(post.likeCount || 0, { - one: '# like', - other: '# likes', - })})`, - comment: - 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', - }), - ) - : _( - msg({ - message: `Like (${plural(post.likeCount || 0, { - one: '# like', - other: '# likes', - })})`, - comment: - 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', - }), - ) - }> - - + + requireAuth(() => onPressReply()) + : undefined + } + label={_( + msg({ + message: `Reply (${plural(post.replyCount || 0, { + one: '# reply', + other: '# replies', + })})`, + comment: + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', + }), + )} + big={big}> + + {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( + + {formatPostStatCount(i18n, post.replyCount, { + compact: variant === 'compact', + })} + + )} + + + + - - - - - + + + onPress={() => requireAuth(() => onPressToggleLike())} + label={ + post.viewer?.like + ? _( + msg({ + message: `Unlike (${plural(post.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', + }), + ) + : _( + msg({ + message: `Like (${plural(post.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', + }), + ) + }> + + + + {/* Spacer! */} + - + + + diff --git a/src/components/PostControls/util.ts b/src/components/PostControls/util.ts new file mode 100644 index 000000000..5d3ea74e4 --- /dev/null +++ b/src/components/PostControls/util.ts @@ -0,0 +1,24 @@ +import {type I18n} from '@lingui/core' + +/** + * This matches `formatCount` from `view/com/util/numeric/format.ts`, but has + * additional truncation logic for large numbers. `roundingMode` should always + * match the original impl, regardless of if we add more formatting here. + */ +export function formatPostStatCount( + i18n: I18n, + count: number, + { + compact = false, + }: { + compact?: boolean + } = {}, +): string { + const isOver10k = count >= 10_000 + return i18n.number(count, { + notation: 'compact', + maximumFractionDigits: isOver10k || compact ? 0 : 1, + // @ts-expect-error - roundingMode not in the types + roundingMode: 'trunc', + }) +} -- cgit 1.4.1