An ATproto social media client -- with an independent Appview.

📓 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>

authored by Eric Bailey Samuel Newman and committed by GitHub 535d4d6c 04b86971

+1
assets/icons/bookmark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="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 2 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"/></svg>
+1
assets/icons/bookmarkDeleteLarge.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#405168" d="M14.2 2.625c.834 0 1.482 0 2.001.042.523.043.949.131 1.331.326a3.38 3.38 0 0 1 1.475 1.475c.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.6.6 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.38 1.38 0 0 1-1.038-.534c-.172-.223-.231-.507-.259-.808a7 7 0 0 1-.024-.528l-.003-.67V7.8c0-.834 0-1.482.042-2.001.043-.523.13-.949.325-1.331a3.38 3.38 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.63 2.63 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.63.63 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.63.63 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.63 2.63 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"/></svg>
+1
assets/icons/bookmarkFilled.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006AFF" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 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"/></svg>
+1
assets/icons/reply.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="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"/></svg>
+1
assets/icons/replyFiled.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="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"/></svg>
assets/images/bookmarks_announcement_nux.webp

This is a binary file and will not be displayed.

+3
bskyweb/cmd/bskyweb/server.go
··· 331 331 e.GET("/starter-pack-short/:code", server.WebGeneric) 332 332 e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack) 333 333 334 + // bookmarks 335 + e.GET("/saved", server.WebGeneric) 336 + 334 337 // ipcc 335 338 e.GET("/ipcc", server.WebIpCC) 336 339
+1 -1
package.json
··· 71 71 "icons:optimize": "svgo -f ./assets/icons" 72 72 }, 73 73 "dependencies": { 74 - "@atproto/api": "^0.16.2", 74 + "@atproto/api": "^0.16.7", 75 75 "@bitdrift/react-native": "^0.6.8", 76 76 "@braintree/sanitize-url": "^6.0.2", 77 77 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+9
src/Navigation.tsx
··· 71 71 import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' 72 72 import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' 73 73 import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' 74 + import {BookmarksScreen} from '#/screens/Bookmarks' 74 75 import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' 75 76 import HashtagScreen from '#/screens/Hashtag' 76 77 import {LogScreen} from '#/screens/Log' ··· 597 598 getComponent={() => VideoFeed} 598 599 options={{ 599 600 title: title(msg`Video Feed`), 601 + requireAuth: true, 602 + }} 603 + /> 604 + <Stack.Screen 605 + name="Bookmarks" 606 + getComponent={() => BookmarksScreen} 607 + options={{ 608 + title: title(msg`Saved Posts`), 600 609 requireAuth: true, 601 610 }} 602 611 />
+136
src/components/PostControls/BookmarkButton.tsx
··· 1 + import {memo} from 'react' 2 + import {type Insets} from 'react-native' 3 + import {type AppBskyFeedDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import type React from 'react' 7 + 8 + import {useCleanError} from '#/lib/hooks/useCleanError' 9 + import {logger} from '#/logger' 10 + import {type Shadow} from '#/state/cache/post-shadow' 11 + import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 12 + import {useTheme} from '#/alf' 13 + import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 14 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 15 + import * as toast from '#/components/Toast' 16 + import {PostControlButton, PostControlButtonIcon} from './PostControlButton' 17 + 18 + export const BookmarkButton = memo(function BookmarkButton({ 19 + post, 20 + big, 21 + logContext, 22 + hitSlop, 23 + }: { 24 + post: Shadow<AppBskyFeedDefs.PostView> 25 + big?: boolean 26 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 27 + hitSlop?: Insets 28 + }): React.ReactNode { 29 + const t = useTheme() 30 + const {_} = useLingui() 31 + const {mutateAsync: bookmark} = useBookmarkMutation() 32 + const cleanError = useCleanError() 33 + 34 + const {viewer} = post 35 + const isBookmarked = !!viewer?.bookmarked 36 + 37 + const undoLabel = _( 38 + msg({ 39 + message: `Undo`, 40 + context: `Button label to undo saving/removing a post from saved posts.`, 41 + }), 42 + ) 43 + 44 + const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => { 45 + try { 46 + await bookmark({ 47 + action: 'create', 48 + post, 49 + }) 50 + 51 + logger.metric('post:bookmark', {logContext}) 52 + 53 + toast.show( 54 + <toast.Outer> 55 + <toast.Icon /> 56 + <toast.Text> 57 + <Trans>Post saved</Trans> 58 + </toast.Text> 59 + {!disableUndo && ( 60 + <toast.Action 61 + label={undoLabel} 62 + onPress={() => remove({disableUndo: true})}> 63 + {undoLabel} 64 + </toast.Action> 65 + )} 66 + </toast.Outer>, 67 + { 68 + type: 'success', 69 + }, 70 + ) 71 + } catch (e: any) { 72 + const {raw, clean} = cleanError(e) 73 + toast.show(clean || raw || e, { 74 + type: 'error', 75 + }) 76 + } 77 + } 78 + 79 + const remove = async ({disableUndo}: {disableUndo?: boolean} = {}) => { 80 + try { 81 + await bookmark({ 82 + action: 'delete', 83 + uri: post.uri, 84 + }) 85 + 86 + logger.metric('post:unbookmark', {logContext}) 87 + 88 + toast.show( 89 + <toast.Outer> 90 + <toast.Icon icon={TrashIcon} /> 91 + <toast.Text> 92 + <Trans>Removed from saved posts</Trans> 93 + </toast.Text> 94 + {!disableUndo && ( 95 + <toast.Action 96 + label={undoLabel} 97 + onPress={() => save({disableUndo: true})}> 98 + {undoLabel} 99 + </toast.Action> 100 + )} 101 + </toast.Outer>, 102 + ) 103 + } catch (e: any) { 104 + const {raw, clean} = cleanError(e) 105 + toast.show(clean || raw || e, { 106 + type: 'error', 107 + }) 108 + } 109 + } 110 + 111 + const onHandlePress = async () => { 112 + if (isBookmarked) { 113 + await remove() 114 + } else { 115 + await save() 116 + } 117 + } 118 + 119 + return ( 120 + <PostControlButton 121 + testID="postBookmarkBtn" 122 + big={big} 123 + label={ 124 + isBookmarked 125 + ? _(msg`Remove from saved posts`) 126 + : _(msg`Add to saved posts`) 127 + } 128 + onPress={onHandlePress} 129 + hitSlop={hitSlop}> 130 + <PostControlButtonIcon 131 + fill={isBookmarked ? t.palette.primary_500 : undefined} 132 + icon={isBookmarked ? BookmarkFilled : Bookmark} 133 + /> 134 + </PostControlButton> 135 + ) 136 + })
+20 -7
src/components/PostControls/PostControlButton.tsx
··· 1 1 import {createContext, useContext, useMemo} from 'react' 2 - import {type GestureResponderEvent, type View} from 'react-native' 2 + import {type GestureResponderEvent, type Insets, type View} from 'react-native' 3 3 4 - import {POST_CTRL_HITSLOP} from '#/lib/constants' 5 4 import {useHaptics} from '#/lib/haptics' 6 5 import {atoms as a, useTheme} from '#/alf' 7 6 import {Button, type ButtonProps} from '#/components/Button' 8 7 import {type Props as SVGIconProps} from '#/components/icons/common' 9 8 import {Text, type TextProps} from '#/components/Typography' 9 + 10 + export const DEFAULT_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} 10 11 11 12 const PostControlContext = createContext<{ 12 13 big?: boolean ··· 25 26 active, 26 27 activeColor, 27 28 ...props 28 - }: ButtonProps & { 29 + }: Omit<ButtonProps, 'hitSlop'> & { 29 30 ref?: React.Ref<View> 30 31 active?: boolean 31 32 big?: boolean 32 33 color?: string 33 34 activeColor?: string 35 + hitSlop?: Insets 34 36 }) { 35 37 const t = useTheme() 36 38 const playHaptic = useHaptics() ··· 83 85 shape="round" 84 86 variant="ghost" 85 87 color="secondary" 86 - hitSlop={POST_CTRL_HITSLOP} 87 - {...props}> 88 + {...props} 89 + hitSlop={{ 90 + ...DEFAULT_HITSLOP, 91 + ...(props.hitSlop || {}), 92 + }}> 88 93 {typeof children === 'function' ? ( 89 94 args => ( 90 95 <PostControlContext.Provider value={ctx}> ··· 102 107 103 108 export function PostControlButtonIcon({ 104 109 icon: Comp, 105 - }: { 110 + style, 111 + ...rest 112 + }: SVGIconProps & { 106 113 icon: React.ComponentType<SVGIconProps> 107 114 }) { 108 115 const {big, color} = useContext(PostControlContext) 109 116 110 - return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} /> 117 + return ( 118 + <Comp 119 + style={[color, a.pointer_events_none, style]} 120 + {...rest} 121 + width={big ? 22 : 18} 122 + /> 123 + ) 111 124 } 112 125 113 126 export function PostControlButtonText({style, ...props}: TextProps) {
+5 -1
src/components/PostControls/PostMenu/index.tsx
··· 1 1 import {memo, useMemo, useState} from 'react' 2 + import {type Insets} from 'react-native' 2 3 import { 3 4 type AppBskyFeedDefs, 4 5 type AppBskyFeedPost, ··· 28 29 timestamp, 29 30 threadgateRecord, 30 31 onShowLess, 32 + hitSlop, 31 33 }: { 32 34 testID: string 33 35 post: Shadow<AppBskyFeedDefs.PostView> ··· 39 41 timestamp: string 40 42 threadgateRecord?: AppBskyFeedThreadgate.Record 41 43 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 44 + hitSlop?: Insets 42 45 }): React.ReactNode => { 43 46 const {_} = useLingui() 44 47 ··· 66 69 testID="postDropdownBtn" 67 70 big={big} 68 71 label={props.accessibilityLabel} 69 - {...props}> 72 + {...props} 73 + hitSlop={hitSlop}> 70 74 <PostControlButtonIcon icon={DotsHorizontal} /> 71 75 </PostControlButton> 72 76 )
+5 -3
src/components/PostControls/RepostButton.tsx
··· 5 5 6 6 import {useHaptics} from '#/lib/haptics' 7 7 import {useRequireAuth} from '#/state/session' 8 - import {formatCount} from '#/view/com/util/numeric/format' 9 8 import {atoms as a, useTheme} from '#/alf' 10 9 import {Button, ButtonText} from '#/components/Button' 11 10 import * as Dialog from '#/components/Dialog' 12 11 import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' 13 - import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 12 + import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost' 13 + import {formatPostStatCount} from '#/components/PostControls/util' 14 14 import {Text} from '#/components/Typography' 15 15 import { 16 16 PostControlButton, ··· 25 25 onQuote: () => void 26 26 big?: boolean 27 27 embeddingDisabled: boolean 28 + compactCount?: boolean 28 29 } 29 30 30 31 let RepostButton = ({ ··· 34 35 onQuote, 35 36 big, 36 37 embeddingDisabled, 38 + compactCount, 37 39 }: Props): React.ReactNode => { 38 40 const t = useTheme() 39 41 const {_, i18n} = useLingui() ··· 86 88 <PostControlButtonIcon icon={Repost} /> 87 89 {typeof repostCount !== 'undefined' && repostCount > 0 && ( 88 90 <PostControlButtonText testID="repostCount"> 89 - {formatCount(i18n, repostCount)} 91 + {formatPostStatCount(i18n, repostCount, {compact: compactCount})} 90 92 </PostControlButtonText> 91 93 )} 92 94 </PostControlButton>
+5 -1
src/components/PostControls/ShareMenu/index.tsx
··· 1 1 import {memo, useMemo, useState} from 'react' 2 + import {type Insets} from 'react-native' 2 3 import { 3 4 type AppBskyFeedDefs, 4 5 type AppBskyFeedPost, ··· 34 35 timestamp, 35 36 threadgateRecord, 36 37 onShare, 38 + hitSlop, 37 39 }: { 38 40 testID: string 39 41 post: Shadow<AppBskyFeedDefs.PostView> ··· 43 45 timestamp: string 44 46 threadgateRecord?: AppBskyFeedThreadgate.Record 45 47 onShare: () => void 48 + hitSlop?: Insets 46 49 }): React.ReactNode => { 47 50 const {_} = useLingui() 48 51 const gate = useGate() ··· 92 95 big={big} 93 96 label={props.accessibilityLabel} 94 97 {...props} 95 - onLongPress={native(onNativeLongPress)}> 98 + onLongPress={native(onNativeLongPress)} 99 + hitSlop={hitSlop}> 96 100 <PostControlButtonIcon icon={ShareIcon} /> 97 101 </PostControlButton> 98 102 )
+127 -95
src/components/PostControls/index.tsx
··· 24 24 ProgressGuideAction, 25 25 useProgressGuideControls, 26 26 } from '#/state/shell/progress-guide' 27 - import {formatCount} from '#/view/com/util/numeric/format' 28 27 import * as Toast from '#/view/com/util/Toast' 29 - import {atoms as a, useBreakpoints} from '#/alf' 30 - import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 28 + import {atoms as a, flatten, useBreakpoints} from '#/alf' 29 + import {Reply as Bubble} from '#/components/icons/Reply' 30 + import {formatPostStatCount} from '#/components/PostControls/util' 31 + import {BookmarkButton} from './BookmarkButton' 31 32 import { 32 33 PostControlButton, 33 34 PostControlButtonIcon, ··· 51 52 threadgateRecord, 52 53 onShowLess, 53 54 viaRepost, 55 + variant, 54 56 }: { 55 57 big?: boolean 56 58 post: Shadow<AppBskyFeedDefs.PostView> ··· 65 67 threadgateRecord?: AppBskyFeedThreadgate.Record 66 68 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 67 69 viaRepost?: {uri: string; cid: string} 70 + variant?: 'compact' | 'normal' | 'large' 68 71 }): React.ReactNode => { 69 72 const {_, i18n} = useLingui() 70 - const {gtMobile} = useBreakpoints() 71 73 const {openComposer} = useOpenComposer() 72 74 const {feedDescriptor} = useFeedFeedbackContext() 73 75 const [queueLike, queueUnlike] = usePostLikeMutationQueue( ··· 92 94 post.author.viewer?.blockingByList, 93 95 ) 94 96 const replyDisabled = post.viewer?.replyDisabled 97 + const {gtPhone} = useBreakpoints() 95 98 96 99 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) 97 100 ··· 184 187 }) 185 188 } 186 189 190 + const secondaryControlSpacingStyles = flatten([ 191 + {gap: 0}, // default, we want `gap` to be defined on the resulting object 192 + variant !== 'compact' && a.gap_xs, 193 + (big || gtPhone) && a.gap_sm, 194 + ]) 195 + 187 196 return ( 188 197 <View 189 198 style={[ ··· 191 200 a.justify_between, 192 201 a.align_center, 193 202 !big && a.pt_2xs, 203 + a.gap_md, 194 204 style, 195 205 ]}> 196 - <View 197 - style={[ 198 - big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}], 199 - replyDisabled ? {opacity: 0.5} : undefined, 200 - ]}> 201 - <PostControlButton 202 - testID="replyBtn" 203 - onPress={ 204 - !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined 205 - } 206 - label={_( 207 - msg({ 208 - message: `Reply (${plural(post.replyCount || 0, { 209 - one: '# reply', 210 - other: '# replies', 211 - })})`, 212 - comment: 213 - 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 214 - }), 215 - )} 216 - big={big}> 217 - <PostControlButtonIcon icon={Bubble} /> 218 - {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( 219 - <PostControlButtonText> 220 - {formatCount(i18n, post.replyCount)} 221 - </PostControlButtonText> 222 - )} 223 - </PostControlButton> 224 - </View> 225 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 226 - <RepostButton 227 - isReposted={!!post.viewer?.repost} 228 - repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} 229 - onRepost={onRepost} 230 - onQuote={onQuote} 231 - big={big} 232 - embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 233 - /> 234 - </View> 235 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 236 - <PostControlButton 237 - testID="likeBtn" 238 - big={big} 239 - onPress={() => requireAuth(() => onPressToggleLike())} 240 - label={ 241 - post.viewer?.like 242 - ? _( 243 - msg({ 244 - message: `Unlike (${plural(post.likeCount || 0, { 245 - one: '# like', 246 - other: '# likes', 247 - })})`, 248 - comment: 249 - 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 250 - }), 251 - ) 252 - : _( 253 - msg({ 254 - message: `Like (${plural(post.likeCount || 0, { 255 - one: '# like', 256 - other: '# likes', 257 - })})`, 258 - comment: 259 - 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 260 - }), 261 - ) 262 - }> 263 - <AnimatedLikeIcon 264 - isLiked={Boolean(post.viewer?.like)} 265 - big={big} 266 - hasBeenToggled={hasLikeIconBeenToggled} 267 - /> 268 - <CountWheel 269 - likeCount={post.likeCount ?? 0} 206 + <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 207 + <View 208 + style={[ 209 + a.flex_1, 210 + a.align_start, 211 + {marginLeft: big ? -2 : -6}, 212 + replyDisabled ? {opacity: 0.5} : undefined, 213 + ]}> 214 + <PostControlButton 215 + testID="replyBtn" 216 + onPress={ 217 + !replyDisabled 218 + ? () => requireAuth(() => onPressReply()) 219 + : undefined 220 + } 221 + label={_( 222 + msg({ 223 + message: `Reply (${plural(post.replyCount || 0, { 224 + one: '# reply', 225 + other: '# replies', 226 + })})`, 227 + comment: 228 + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 229 + }), 230 + )} 231 + big={big}> 232 + <PostControlButtonIcon icon={Bubble} /> 233 + {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( 234 + <PostControlButtonText> 235 + {formatPostStatCount(i18n, post.replyCount, { 236 + compact: variant === 'compact', 237 + })} 238 + </PostControlButtonText> 239 + )} 240 + </PostControlButton> 241 + </View> 242 + <View style={[a.flex_1, a.align_start]}> 243 + <RepostButton 244 + isReposted={!!post.viewer?.repost} 245 + repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} 246 + onRepost={onRepost} 247 + onQuote={onQuote} 270 248 big={big} 271 - isLiked={Boolean(post.viewer?.like)} 272 - hasBeenToggled={hasLikeIconBeenToggled} 249 + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 250 + compactCount={variant === 'compact'} 273 251 /> 274 - </PostControlButton> 275 - </View> 276 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 277 - <View style={[!big && a.ml_sm]}> 278 - <ShareMenuButton 279 - testID="postShareBtn" 280 - post={post} 252 + </View> 253 + <View style={[a.flex_1, a.align_start]}> 254 + <PostControlButton 255 + testID="likeBtn" 281 256 big={big} 282 - record={record} 283 - richText={richText} 284 - timestamp={post.indexedAt} 285 - threadgateRecord={threadgateRecord} 286 - onShare={onShare} 287 - /> 257 + onPress={() => requireAuth(() => onPressToggleLike())} 258 + label={ 259 + post.viewer?.like 260 + ? _( 261 + msg({ 262 + message: `Unlike (${plural(post.likeCount || 0, { 263 + one: '# like', 264 + other: '# likes', 265 + })})`, 266 + comment: 267 + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 268 + }), 269 + ) 270 + : _( 271 + msg({ 272 + message: `Like (${plural(post.likeCount || 0, { 273 + one: '# like', 274 + other: '# likes', 275 + })})`, 276 + comment: 277 + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 278 + }), 279 + ) 280 + }> 281 + <AnimatedLikeIcon 282 + isLiked={Boolean(post.viewer?.like)} 283 + big={big} 284 + hasBeenToggled={hasLikeIconBeenToggled} 285 + /> 286 + <CountWheel 287 + likeCount={post.likeCount ?? 0} 288 + big={big} 289 + isLiked={Boolean(post.viewer?.like)} 290 + hasBeenToggled={hasLikeIconBeenToggled} 291 + compactCount={variant === 'compact'} 292 + /> 293 + </PostControlButton> 288 294 </View> 295 + {/* Spacer! */} 296 + <View /> 289 297 </View> 290 - <View 291 - style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}> 298 + <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 299 + <BookmarkButton 300 + post={post} 301 + big={big} 302 + logContext={logContext} 303 + hitSlop={{ 304 + right: secondaryControlSpacingStyles.gap / 2, 305 + }} 306 + /> 307 + <ShareMenuButton 308 + testID="postShareBtn" 309 + post={post} 310 + big={big} 311 + record={record} 312 + richText={richText} 313 + timestamp={post.indexedAt} 314 + threadgateRecord={threadgateRecord} 315 + onShare={onShare} 316 + hitSlop={{ 317 + left: secondaryControlSpacingStyles.gap / 2, 318 + right: secondaryControlSpacingStyles.gap / 2, 319 + }} 320 + /> 292 321 <PostMenuButton 293 322 testID="postDropdownBtn" 294 323 post={post} ··· 300 329 timestamp={post.indexedAt} 301 330 threadgateRecord={threadgateRecord} 302 331 onShowLess={onShowLess} 332 + hitSlop={{ 333 + left: secondaryControlSpacingStyles.gap / 2, 334 + }} 303 335 /> 304 336 </View> 305 337 </View>
+24
src/components/PostControls/util.ts
··· 1 + import {type I18n} from '@lingui/core' 2 + 3 + /** 4 + * This matches `formatCount` from `view/com/util/numeric/format.ts`, but has 5 + * additional truncation logic for large numbers. `roundingMode` should always 6 + * match the original impl, regardless of if we add more formatting here. 7 + */ 8 + export function formatPostStatCount( 9 + i18n: I18n, 10 + count: number, 11 + { 12 + compact = false, 13 + }: { 14 + compact?: boolean 15 + } = {}, 16 + ): string { 17 + const isOver10k = count >= 10_000 18 + return i18n.number(count, { 19 + notation: 'compact', 20 + maximumFractionDigits: isOver10k || compact ? 0 : 1, 21 + // @ts-expect-error - roundingMode not in the types 22 + roundingMode: 'trunc', 23 + }) 24 + }
+177
src/components/dialogs/nuxs/BookmarksAnnouncement.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {LinearGradient} from 'expo-linear-gradient' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {isWeb} from '#/platform/detection' 9 + import {atoms as a, useTheme, web} from '#/alf' 10 + import {transparentifyColor} from '#/alf/util/colorGeneration' 11 + import {Button, ButtonText} from '#/components/Button' 12 + import * as Dialog from '#/components/Dialog' 13 + import {useNuxDialogContext} from '#/components/dialogs/nuxs' 14 + import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function BookmarksAnnouncement() { 18 + const t = useTheme() 19 + const {_} = useLingui() 20 + const nuxDialogs = useNuxDialogContext() 21 + const control = Dialog.useDialogControl() 22 + 23 + Dialog.useAutoOpen(control) 24 + 25 + const onClose = useCallback(() => { 26 + nuxDialogs.dismissActiveNux() 27 + }, [nuxDialogs]) 28 + 29 + return ( 30 + <Dialog.Outer 31 + control={control} 32 + onClose={onClose} 33 + nativeOptions={{preventExpansion: true}}> 34 + <Dialog.Handle /> 35 + 36 + <Dialog.ScrollableInner 37 + label={_(msg`Introducing saved posts AKA bookmarks`)} 38 + style={[web({maxWidth: 440})]} 39 + contentContainerStyle={[ 40 + { 41 + paddingTop: 0, 42 + paddingLeft: 0, 43 + paddingRight: 0, 44 + }, 45 + ]}> 46 + <View 47 + style={[ 48 + a.align_center, 49 + a.overflow_hidden, 50 + { 51 + gap: 16, 52 + paddingTop: isWeb ? 24 : 40, 53 + borderTopLeftRadius: a.rounded_md.borderRadius, 54 + borderTopRightRadius: a.rounded_md.borderRadius, 55 + }, 56 + ]}> 57 + <LinearGradient 58 + colors={[t.palette.primary_25, t.palette.primary_100]} 59 + locations={[0, 1]} 60 + start={{x: 0, y: 0}} 61 + end={{x: 0, y: 1}} 62 + style={[a.absolute, a.inset_0]} 63 + /> 64 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 65 + <SparkleIcon fill={t.palette.primary_800} size="sm" /> 66 + <Text 67 + style={[ 68 + a.font_bold, 69 + { 70 + color: t.palette.primary_800, 71 + }, 72 + ]}> 73 + <Trans>New Feature</Trans> 74 + </Text> 75 + </View> 76 + 77 + <View 78 + style={[ 79 + a.relative, 80 + a.w_full, 81 + { 82 + paddingTop: 8, 83 + paddingHorizontal: 32, 84 + paddingBottom: 32, 85 + }, 86 + ]}> 87 + <View 88 + style={[ 89 + { 90 + borderRadius: 24, 91 + aspectRatio: 333 / 104, 92 + }, 93 + isWeb 94 + ? [ 95 + { 96 + boxShadow: `0px 10px 15px -3px ${transparentifyColor(t.palette.black, 0.2)}`, 97 + }, 98 + ] 99 + : [ 100 + t.atoms.shadow_md, 101 + { 102 + shadowOpacity: 0.2, 103 + shadowOffset: { 104 + width: 0, 105 + height: 10, 106 + }, 107 + }, 108 + ], 109 + ]}> 110 + <Image 111 + accessibilityIgnoresInvertColors 112 + source={require('../../../../assets/images/bookmarks_announcement_nux.webp')} 113 + style={[ 114 + a.w_full, 115 + { 116 + aspectRatio: 333 / 104, 117 + }, 118 + ]} 119 + alt={_( 120 + 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".`, 121 + )} 122 + /> 123 + </View> 124 + </View> 125 + </View> 126 + <View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}> 127 + <View style={[a.gap_sm, a.align_center]}> 128 + <Text 129 + style={[ 130 + a.text_3xl, 131 + a.leading_tight, 132 + a.font_heavy, 133 + a.text_center, 134 + { 135 + fontSize: isWeb ? 28 : 32, 136 + maxWidth: 300, 137 + }, 138 + ]}> 139 + <Trans>Saved Posts</Trans> 140 + </Text> 141 + <Text 142 + style={[ 143 + a.text_md, 144 + a.leading_snug, 145 + a.text_center, 146 + { 147 + maxWidth: 340, 148 + }, 149 + ]}> 150 + <Trans> 151 + Finally! Keep track of posts that matter to you. Save them to 152 + revisit anytime. 153 + </Trans> 154 + </Text> 155 + </View> 156 + 157 + {!isWeb && ( 158 + <Button 159 + label={_(msg`Close`)} 160 + size="large" 161 + color="primary" 162 + onPress={() => { 163 + control.close() 164 + }} 165 + style={[a.w_full]}> 166 + <ButtonText> 167 + <Trans>Close</Trans> 168 + </ButtonText> 169 + </Button> 170 + )} 171 + </View> 172 + 173 + <Dialog.Close /> 174 + </Dialog.ScrollableInner> 175 + </Dialog.Outer> 176 + ) 177 + }
+3 -10
src/components/dialogs/nuxs/index.tsx
··· 12 12 import {useProfileQuery} from '#/state/queries/profile' 13 13 import {type SessionAccount, useSession} from '#/state/session' 14 14 import {useOnboardingState} from '#/state/shell' 15 - import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions' 15 + import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement' 16 16 /* 17 17 * NUXs 18 18 */ 19 19 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 20 - import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils' 21 20 22 21 type Context = { 23 22 activeNux: Nux | undefined ··· 34 33 }) => boolean 35 34 }[] = [ 36 35 { 37 - id: Nux.ActivitySubscriptions, 38 - enabled: ({currentProfile}) => { 39 - return isExistingUserAsOf( 40 - '2025-07-07T00:00:00.000Z', 41 - currentProfile.createdAt, 42 - ) 43 - }, 36 + id: Nux.BookmarksAnnouncement, 44 37 }, 45 38 ] 46 39 ··· 180 173 return ( 181 174 <Context.Provider value={ctx}> 182 175 {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} 183 - {activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />} 176 + {activeNux === Nux.BookmarksAnnouncement && <BookmarksAnnouncement />} 184 177 </Context.Provider> 185 178 ) 186 179 }
+16
src/components/icons/Bookmark.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + // custom, not part of icon library 4 + export const Bookmark = createSinglePathSVG({ 5 + 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', 6 + }) 7 + 8 + // custom, not part of icon library 9 + export const BookmarkFilled = createSinglePathSVG({ 10 + 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', 11 + }) 12 + 13 + // custom, not part of icon library, for LARGE (64px) size 14 + export const BookmarkDeleteLarge = createSinglePathSVG({ 15 + 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', 16 + })
+11
src/components/icons/Reply.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + // custom, off spec 4 + export const Reply = createSinglePathSVG({ 5 + 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', 6 + }) 7 + 8 + // custom, off spec 9 + export const ReplyFilled = createSinglePathSVG({ 10 + 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', 11 + })
-1
src/lib/constants.ts
··· 124 124 export const HITSLOP_10 = createHitslop(10) 125 125 export const HITSLOP_20 = createHitslop(20) 126 126 export const HITSLOP_30 = createHitslop(30) 127 - export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} 128 127 export const LANG_DROPDOWN_HITSLOP = {top: 10, bottom: 10, left: 4, right: 4} 129 128 export const BACK_HITSLOP = HITSLOP_30 130 129 export const MAX_POST_LINES = 25
+9 -3
src/lib/custom-animations/CountWheel.tsx
··· 10 10 11 11 import {decideShouldRoll} from '#/lib/custom-animations/util' 12 12 import {s} from '#/lib/styles' 13 - import {formatCount} from '#/view/com/util/numeric/format' 14 13 import {Text} from '#/view/com/util/text/Text' 15 14 import {atoms as a, useTheme} from '#/alf' 15 + import {formatPostStatCount} from '#/components/PostControls/util' 16 16 17 17 const animationConfig = { 18 18 duration: 400, ··· 92 92 big, 93 93 isLiked, 94 94 hasBeenToggled, 95 + compactCount, 95 96 }: { 96 97 likeCount: number 97 98 big?: boolean 98 99 isLiked: boolean 99 100 hasBeenToggled: boolean 101 + compactCount?: boolean 100 102 }) { 101 103 const t = useTheme() 102 104 const shouldAnimate = !useReducedMotion() && hasBeenToggled ··· 109 111 const [key, setKey] = React.useState(0) 110 112 const [prevCount, setPrevCount] = React.useState(likeCount) 111 113 const prevIsLiked = React.useRef(isLiked) 112 - const formattedCount = formatCount(i18n, likeCount) 113 - const formattedPrevCount = formatCount(i18n, prevCount) 114 + const formattedCount = formatPostStatCount(i18n, likeCount, { 115 + compact: compactCount, 116 + }) 117 + const formattedPrevCount = formatPostStatCount(i18n, prevCount, { 118 + compact: compactCount, 119 + }) 114 120 115 121 React.useEffect(() => { 116 122 if (isLiked === prevIsLiked.current) {
+1
src/lib/hooks/useNavigationTabState.ts
··· 9 9 isAtSearch: getTabState(state, 'Search') !== TabState.Outside, 10 10 // FeedsTab no longer exists, but this check works for `Feeds` screen as well 11 11 isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside, 12 + isAtBookmarks: getTabState(state, 'Bookmarks') !== TabState.Outside, 12 13 isAtNotifications: 13 14 getTabState(state, 'Notifications') !== TabState.Outside, 14 15 isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
+1
src/lib/routes/types.ts
··· 86 86 } 87 87 StarterPackEdit: {rkey?: string} 88 88 VideoFeed: VideoFeedSourceContext 89 + Bookmarks: undefined 89 90 } 90 91 91 92 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+8
src/logger/metrics.ts
··· 238 238 'post:unmute': {} 239 239 'post:pin': {} 240 240 'post:unpin': {} 241 + 'post:bookmark': { 242 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 243 + } 244 + 'post:unbookmark': { 245 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 246 + } 247 + 'bookmarks:view': {} 248 + 'bookmarks:post-clicked': {} 241 249 'profile:follow': { 242 250 didBecomeMutual: boolean | undefined 243 251 followeeClout: number | undefined
+1
src/routes.ts
··· 90 90 StarterPackShort: '/starter-pack-short/:code', 91 91 StarterPackWizard: '/starter-pack/create', 92 92 VideoFeed: '/video-feed', 93 + Bookmarks: '/saved', 93 94 })
+59
src/screens/Bookmarks/components/EmptyState.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {ButtonText} from '#/components/Button' 7 + import {BookmarkDeleteLarge} from '#/components/icons/Bookmark' 8 + import {Link} from '#/components/Link' 9 + import {Text} from '#/components/Typography' 10 + 11 + export function EmptyState() { 12 + const t = useTheme() 13 + const {_} = useLingui() 14 + 15 + return ( 16 + <View 17 + style={[ 18 + a.align_center, 19 + { 20 + paddingVertical: 64, 21 + }, 22 + ]}> 23 + <BookmarkDeleteLarge 24 + width={64} 25 + fill={t.atoms.text_contrast_medium.color} 26 + /> 27 + <View style={[a.pt_sm]}> 28 + <Text 29 + style={[ 30 + a.text_lg, 31 + a.font_medium, 32 + a.text_center, 33 + t.atoms.text_contrast_medium, 34 + ]}> 35 + <Trans>Nothing saved yet</Trans> 36 + </Text> 37 + </View> 38 + <View style={[a.pt_2xl]}> 39 + <Link 40 + to="/" 41 + action="navigate" 42 + label={_( 43 + msg({ 44 + message: `Go home`, 45 + context: `Button to go back to the home timeline`, 46 + }), 47 + )} 48 + size="small" 49 + color="secondary"> 50 + <ButtonText> 51 + <Trans context="Button to go back to the home timeline"> 52 + Go home 53 + </Trans> 54 + </ButtonText> 55 + </Link> 56 + </View> 57 + </View> 58 + ) 59 + }
+294
src/screens/Bookmarks/index.tsx
··· 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + type $Typed, 5 + type AppBskyBookmarkDefs, 6 + AppBskyFeedDefs, 7 + } from '@atproto/api' 8 + import {msg, Trans} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + import {useFocusEffect} from '@react-navigation/native' 11 + 12 + import {useCleanError} from '#/lib/hooks/useCleanError' 13 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 14 + import { 15 + type CommonNavigatorParams, 16 + type NativeStackScreenProps, 17 + } from '#/lib/routes/types' 18 + import {logger} from '#/logger' 19 + import {isIOS} from '#/platform/detection' 20 + import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 21 + import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery' 22 + import {useSetMinimalShellMode} from '#/state/shell' 23 + import {Post} from '#/view/com/post/Post' 24 + import {List} from '#/view/com/util/List' 25 + import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 26 + import {EmptyState} from '#/screens/Bookmarks/components/EmptyState' 27 + import {atoms as a, useTheme} from '#/alf' 28 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 29 + import {BookmarkFilled} from '#/components/icons/Bookmark' 30 + import {CircleQuestion_Stroke2_Corner2_Rounded as QuestionIcon} from '#/components/icons/CircleQuestion' 31 + import * as Layout from '#/components/Layout' 32 + import {ListFooter} from '#/components/Lists' 33 + import * as Skele from '#/components/Skeleton' 34 + import * as toast from '#/components/Toast' 35 + import {Text} from '#/components/Typography' 36 + 37 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'> 38 + 39 + export function BookmarksScreen({}: Props) { 40 + const setMinimalShellMode = useSetMinimalShellMode() 41 + 42 + useFocusEffect( 43 + useCallback(() => { 44 + setMinimalShellMode(false) 45 + logger.metric('bookmarks:view', {}) 46 + }, [setMinimalShellMode]), 47 + ) 48 + 49 + return ( 50 + <Layout.Screen testID="bookmarksScreen"> 51 + <Layout.Header.Outer> 52 + <Layout.Header.BackButton /> 53 + <Layout.Header.Content> 54 + <Layout.Header.TitleText> 55 + <Trans>Saved Posts</Trans> 56 + </Layout.Header.TitleText> 57 + </Layout.Header.Content> 58 + <Layout.Header.Slot /> 59 + </Layout.Header.Outer> 60 + <BookmarksInner /> 61 + </Layout.Screen> 62 + ) 63 + } 64 + 65 + type ListItem = 66 + | { 67 + type: 'loading' 68 + key: 'loading' 69 + } 70 + | { 71 + type: 'empty' 72 + key: 'empty' 73 + } 74 + | { 75 + type: 'bookmark' 76 + key: string 77 + bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & { 78 + item: $Typed<AppBskyFeedDefs.PostView> 79 + } 80 + } 81 + | { 82 + type: 'bookmarkNotFound' 83 + key: string 84 + bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & { 85 + item: $Typed<AppBskyFeedDefs.NotFoundPost> 86 + } 87 + } 88 + 89 + function BookmarksInner() { 90 + const initialNumToRender = useInitialNumToRender() 91 + const cleanError = useCleanError() 92 + const [isPTRing, setIsPTRing] = useState(false) 93 + const { 94 + data, 95 + isLoading, 96 + isFetchingNextPage, 97 + hasNextPage, 98 + fetchNextPage, 99 + error, 100 + refetch, 101 + } = useBookmarksQuery() 102 + const cleanedError = useMemo(() => { 103 + const {raw, clean} = cleanError(error) 104 + return clean || raw 105 + }, [error, cleanError]) 106 + 107 + const onRefresh = useCallback(async () => { 108 + setIsPTRing(true) 109 + try { 110 + await refetch() 111 + } finally { 112 + setIsPTRing(false) 113 + } 114 + }, [refetch, setIsPTRing]) 115 + 116 + const onEndReached = useCallback(async () => { 117 + if (isFetchingNextPage || !hasNextPage || error) return 118 + try { 119 + await fetchNextPage() 120 + } catch {} 121 + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 122 + 123 + const items = useMemo(() => { 124 + const i: ListItem[] = [] 125 + 126 + if (isLoading) { 127 + i.push({type: 'loading', key: 'loading'}) 128 + } else if (error || !data) { 129 + // handled in Footer 130 + } else { 131 + const bookmarks = data.pages.flatMap(p => p.bookmarks) 132 + 133 + if (bookmarks.length > 0) { 134 + for (const bookmark of bookmarks) { 135 + if (AppBskyFeedDefs.isNotFoundPost(bookmark.item)) { 136 + i.push({ 137 + type: 'bookmarkNotFound', 138 + key: bookmark.item.uri, 139 + bookmark: { 140 + ...bookmark, 141 + item: bookmark.item as $Typed<AppBskyFeedDefs.NotFoundPost>, 142 + }, 143 + }) 144 + } 145 + if (AppBskyFeedDefs.isPostView(bookmark.item)) { 146 + i.push({ 147 + type: 'bookmark', 148 + key: bookmark.item.uri, 149 + bookmark: { 150 + ...bookmark, 151 + item: bookmark.item as $Typed<AppBskyFeedDefs.PostView>, 152 + }, 153 + }) 154 + } 155 + } 156 + } else { 157 + i.push({type: 'empty', key: 'empty'}) 158 + } 159 + } 160 + 161 + return i 162 + }, [isLoading, error, data]) 163 + 164 + const isEmpty = items.length === 1 && items[0]?.type === 'empty' 165 + 166 + return ( 167 + <List 168 + data={items} 169 + renderItem={renderItem} 170 + keyExtractor={keyExtractor} 171 + refreshing={isPTRing} 172 + onRefresh={onRefresh} 173 + onEndReached={onEndReached} 174 + onEndReachedThreshold={4} 175 + ListFooterComponent={ 176 + <ListFooter 177 + isFetchingNextPage={isFetchingNextPage} 178 + error={cleanedError} 179 + onRetry={fetchNextPage} 180 + style={[isEmpty && a.border_t_0]} 181 + /> 182 + } 183 + initialNumToRender={initialNumToRender} 184 + windowSize={9} 185 + maxToRenderPerBatch={isIOS ? 5 : 1} 186 + updateCellsBatchingPeriod={40} 187 + sideBorders={false} 188 + /> 189 + ) 190 + } 191 + 192 + function BookmarkNotFound({ 193 + hideTopBorder, 194 + post, 195 + }: { 196 + hideTopBorder: boolean 197 + post: $Typed<AppBskyFeedDefs.NotFoundPost> 198 + }) { 199 + const t = useTheme() 200 + const {_} = useLingui() 201 + const {mutateAsync: bookmark} = useBookmarkMutation() 202 + const cleanError = useCleanError() 203 + 204 + const remove = async () => { 205 + try { 206 + await bookmark({action: 'delete', uri: post.uri}) 207 + toast.show(_(msg`Removed from saved posts`), { 208 + type: 'info', 209 + }) 210 + } catch (e: any) { 211 + const {raw, clean} = cleanError(e) 212 + toast.show(clean || raw || e, { 213 + type: 'error', 214 + }) 215 + } 216 + } 217 + 218 + return ( 219 + <View 220 + style={[ 221 + a.flex_row, 222 + a.align_start, 223 + a.px_xl, 224 + a.py_lg, 225 + a.gap_sm, 226 + !hideTopBorder && a.border_t, 227 + t.atoms.border_contrast_low, 228 + ]}> 229 + <Skele.Circle size={42}> 230 + <QuestionIcon size="lg" fill={t.atoms.text_contrast_low.color} /> 231 + </Skele.Circle> 232 + <View style={[a.flex_1, a.gap_2xs]}> 233 + <View style={[a.flex_row, a.gap_xs]}> 234 + <Skele.Text style={[a.text_md, {width: 80}]} /> 235 + <Skele.Text style={[a.text_md, {width: 100}]} /> 236 + </View> 237 + 238 + <Text 239 + style={[ 240 + a.text_md, 241 + a.leading_snug, 242 + a.italic, 243 + t.atoms.text_contrast_medium, 244 + ]}> 245 + <Trans>This post was deleted by its author</Trans> 246 + </Text> 247 + </View> 248 + <Button 249 + label={_(msg`Remove from saved posts`)} 250 + size="tiny" 251 + color="secondary" 252 + onPress={remove}> 253 + <ButtonIcon icon={BookmarkFilled} /> 254 + <ButtonText> 255 + <Trans>Remove</Trans> 256 + </ButtonText> 257 + </Button> 258 + </View> 259 + ) 260 + } 261 + 262 + function renderItem({item, index}: {item: ListItem; index: number}) { 263 + switch (item.type) { 264 + case 'loading': { 265 + return <PostFeedLoadingPlaceholder /> 266 + } 267 + case 'empty': { 268 + return <EmptyState /> 269 + } 270 + case 'bookmark': { 271 + return ( 272 + <Post 273 + post={item.bookmark.item} 274 + hideTopBorder={index === 0} 275 + onBeforePress={() => { 276 + logger.metric('bookmarks:post-clicked', {}) 277 + }} 278 + /> 279 + ) 280 + } 281 + case 'bookmarkNotFound': { 282 + return ( 283 + <BookmarkNotFound 284 + post={item.bookmark.item} 285 + hideTopBorder={index === 0} 286 + /> 287 + ) 288 + } 289 + default: 290 + return null 291 + } 292 + } 293 + 294 + const keyExtractor = (item: ListItem) => item.key
+27 -6
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 32 32 import {type OnPostSuccessData} from '#/state/shell/composer' 33 33 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 34 34 import {type PostSource} from '#/state/unstable-post-source' 35 - import {formatCount} from '#/view/com/util/numeric/format' 36 35 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 37 36 import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' 38 37 import { ··· 53 52 import {type AppModerationCause} from '#/components/Pills' 54 53 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 55 54 import {PostControls} from '#/components/PostControls' 55 + import {formatPostStatCount} from '#/components/PostControls/util' 56 56 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 57 57 import * as Prompt from '#/components/Prompt' 58 58 import {RichText} from '#/components/RichText' ··· 415 415 /> 416 416 {post.repostCount !== 0 || 417 417 post.likeCount !== 0 || 418 - post.quoteCount !== 0 ? ( 418 + post.quoteCount !== 0 || 419 + post.bookmarkCount !== 0 ? ( 419 420 // Show this section unless we're *sure* it has no engagement. 420 421 <View 421 422 style={[ 422 423 a.flex_row, 424 + a.flex_wrap, 423 425 a.align_center, 424 - a.gap_lg, 426 + { 427 + rowGap: a.gap_sm.gap, 428 + columnGap: a.gap_lg.gap, 429 + }, 425 430 a.border_t, 426 431 a.border_b, 427 432 a.mt_md, ··· 434 439 testID="repostCount-expanded" 435 440 style={[a.text_md, t.atoms.text_contrast_medium]}> 436 441 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 437 - {formatCount(i18n, post.repostCount)} 442 + {formatPostStatCount(i18n, post.repostCount)} 438 443 </Text>{' '} 439 444 <Plural 440 445 value={post.repostCount} ··· 452 457 testID="quoteCount-expanded" 453 458 style={[a.text_md, t.atoms.text_contrast_medium]}> 454 459 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 455 - {formatCount(i18n, post.quoteCount)} 460 + {formatPostStatCount(i18n, post.quoteCount)} 456 461 </Text>{' '} 457 462 <Plural 458 463 value={post.quoteCount} ··· 468 473 testID="likeCount-expanded" 469 474 style={[a.text_md, t.atoms.text_contrast_medium]}> 470 475 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 471 - {formatCount(i18n, post.likeCount)} 476 + {formatPostStatCount(i18n, post.likeCount)} 472 477 </Text>{' '} 473 478 <Plural value={post.likeCount} one="like" other="likes" /> 479 + </Text> 480 + </Link> 481 + ) : null} 482 + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( 483 + <Link to={likesHref} label={_(msg`Saves of this post`)}> 484 + <Text 485 + testID="bookmarkCount-expanded" 486 + style={[a.text_md, t.atoms.text_contrast_medium]}> 487 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 488 + {formatPostStatCount(i18n, post.bookmarkCount)} 489 + </Text>{' '} 490 + <Plural 491 + value={post.bookmarkCount} 492 + one="save" 493 + other="saves" 494 + /> 474 495 </Text> 475 496 </Link> 476 497 ) : null}
+1
src/screens/PostThread/components/ThreadItemTreePost.tsx
··· 368 368 </View> 369 369 )} 370 370 <PostControls 371 + variant="compact" 371 372 post={postShadow} 372 373 record={record} 373 374 richText={richText}
+16
src/state/cache/post-shadow.ts
··· 25 25 embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined 26 26 pinned: boolean 27 27 optimisticReplyCount: number | undefined 28 + bookmarked: boolean | undefined 28 29 } 29 30 30 31 export const POST_TOMBSTONE = Symbol('PostTombstone') ··· 92 93 likeCount = Math.max(0, likeCount) 93 94 } 94 95 96 + let bookmarkCount = post.bookmarkCount ?? 0 97 + if ('bookmarked' in shadow) { 98 + const wasBookmarked = !!post.viewer?.bookmarked 99 + const isBookmarked = !!shadow.bookmarked 100 + if (wasBookmarked && !isBookmarked) { 101 + bookmarkCount-- 102 + } else if (!wasBookmarked && isBookmarked) { 103 + bookmarkCount++ 104 + } 105 + bookmarkCount = Math.max(0, bookmarkCount) 106 + } 107 + 95 108 let repostCount = post.repostCount ?? 0 96 109 if ('repostUri' in shadow) { 97 110 const wasReposted = !!post.viewer?.repost ··· 127 140 likeCount: likeCount, 128 141 repostCount: repostCount, 129 142 replyCount: replyCount, 143 + bookmarkCount: bookmarkCount, 130 144 viewer: { 131 145 ...(post.viewer || {}), 132 146 like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, 133 147 repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, 134 148 pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned, 149 + bookmarked: 150 + 'bookmarked' in shadow ? shadow.bookmarked : post.viewer?.bookmarked, 135 151 }, 136 152 }) 137 153 }
+65
src/state/queries/bookmarks/useBookmarkMutation.ts
··· 1 + import {type AppBskyFeedDefs} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {isNetworkError} from '#/lib/strings/errors' 5 + import {logger} from '#/logger' 6 + import {updatePostShadow} from '#/state/cache/post-shadow' 7 + import { 8 + optimisticallyDeleteBookmark, 9 + optimisticallySaveBookmark, 10 + } from '#/state/queries/bookmarks/useBookmarksQuery' 11 + import {useAgent} from '#/state/session' 12 + 13 + type MutationArgs = 14 + | {action: 'create'; post: AppBskyFeedDefs.PostView} 15 + | { 16 + action: 'delete' 17 + /** 18 + * For deletions, we only need to URI. Plus, in some cases we only know the 19 + * URI, such as when a post was deleted by the author. 20 + */ 21 + uri: string 22 + } 23 + 24 + export function useBookmarkMutation() { 25 + const qc = useQueryClient() 26 + const agent = useAgent() 27 + 28 + return useMutation({ 29 + async mutationFn(args: MutationArgs) { 30 + if (args.action === 'create') { 31 + updatePostShadow(qc, args.post.uri, {bookmarked: true}) 32 + await agent.app.bsky.bookmark.createBookmark({ 33 + uri: args.post.uri, 34 + cid: args.post.cid, 35 + }) 36 + } else if (args.action === 'delete') { 37 + updatePostShadow(qc, args.uri, {bookmarked: false}) 38 + await agent.app.bsky.bookmark.deleteBookmark({ 39 + uri: args.uri, 40 + }) 41 + } 42 + }, 43 + onSuccess(_, args) { 44 + if (args.action === 'create') { 45 + optimisticallySaveBookmark(qc, args.post) 46 + } else if (args.action === 'delete') { 47 + optimisticallyDeleteBookmark(qc, {uri: args.uri}) 48 + } 49 + }, 50 + onError(e, args) { 51 + if (args.action === 'create') { 52 + updatePostShadow(qc, args.post.uri, {bookmarked: false}) 53 + } else if (args.action === 'delete') { 54 + updatePostShadow(qc, args.uri, {bookmarked: true}) 55 + } 56 + 57 + if (!isNetworkError(e)) { 58 + logger.error('bookmark mutation failed', { 59 + bookmarkAction: args.action, 60 + safeMessage: e, 61 + }) 62 + } 63 + }, 64 + }) 65 + }
+114
src/state/queries/bookmarks/useBookmarksQuery.ts
··· 1 + import { 2 + type $Typed, 3 + type AppBskyBookmarkGetBookmarks, 4 + type AppBskyFeedDefs, 5 + } from '@atproto/api' 6 + import { 7 + type InfiniteData, 8 + type QueryClient, 9 + type QueryKey, 10 + useInfiniteQuery, 11 + } from '@tanstack/react-query' 12 + 13 + import {useAgent} from '#/state/session' 14 + 15 + export const bookmarksQueryKeyRoot = 'bookmarks' 16 + export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot] 17 + 18 + export function useBookmarksQuery() { 19 + const agent = useAgent() 20 + 21 + return useInfiniteQuery< 22 + AppBskyBookmarkGetBookmarks.OutputSchema, 23 + Error, 24 + InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>, 25 + QueryKey, 26 + string | undefined 27 + >({ 28 + queryKey: createBookmarksQueryKey(), 29 + async queryFn({pageParam}) { 30 + const res = await agent.app.bsky.bookmark.getBookmarks({ 31 + cursor: pageParam, 32 + }) 33 + return res.data 34 + }, 35 + initialPageParam: undefined, 36 + getNextPageParam: lastPage => lastPage.cursor, 37 + }) 38 + } 39 + 40 + export async function truncateAndInvalidate(qc: QueryClient) { 41 + qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( 42 + {queryKey: [bookmarksQueryKeyRoot]}, 43 + data => { 44 + if (data) { 45 + return { 46 + pageParams: data.pageParams.slice(0, 1), 47 + pages: data.pages.slice(0, 1), 48 + } 49 + } 50 + return data 51 + }, 52 + ) 53 + return qc.invalidateQueries({queryKey: [bookmarksQueryKeyRoot]}) 54 + } 55 + 56 + export async function optimisticallySaveBookmark( 57 + qc: QueryClient, 58 + post: AppBskyFeedDefs.PostView, 59 + ) { 60 + qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( 61 + { 62 + queryKey: [bookmarksQueryKeyRoot], 63 + }, 64 + data => { 65 + if (!data) return data 66 + return { 67 + ...data, 68 + pages: data.pages.map((page, index) => { 69 + if (index === 0) { 70 + post.$type = 'app.bsky.feed.defs#postView' 71 + return { 72 + ...page, 73 + bookmarks: [ 74 + { 75 + createdAt: new Date().toISOString(), 76 + subject: { 77 + uri: post.uri, 78 + cid: post.cid, 79 + }, 80 + item: post as $Typed<AppBskyFeedDefs.PostView>, 81 + }, 82 + ...page.bookmarks, 83 + ], 84 + } 85 + } 86 + return page 87 + }), 88 + } 89 + }, 90 + ) 91 + } 92 + 93 + export async function optimisticallyDeleteBookmark( 94 + qc: QueryClient, 95 + {uri}: {uri: string}, 96 + ) { 97 + qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( 98 + { 99 + queryKey: [bookmarksQueryKeyRoot], 100 + }, 101 + data => { 102 + if (!data) return data 103 + return { 104 + ...data, 105 + pages: data.pages.map(page => { 106 + return { 107 + ...page, 108 + bookmarks: page.bookmarks.filter(b => b.subject.uri !== uri), 109 + } 110 + }), 111 + } 112 + }, 113 + ) 114 + }
+6
src/state/queries/nuxs/definitions.ts
··· 9 9 ActivitySubscriptions = 'ActivitySubscriptions', 10 10 AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', 11 11 AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner', 12 + BookmarksAnnouncement = 'BookmarksAnnouncement', 12 13 13 14 /* 14 15 * Blocking announcements. New IDs are required for each new announcement. ··· 47 48 id: Nux.PolicyUpdate202508 48 49 data: undefined 49 50 } 51 + | { 52 + id: Nux.BookmarksAnnouncement 53 + data: undefined 54 + } 50 55 > 51 56 52 57 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { ··· 57 62 [Nux.AgeAssuranceDismissibleNotice]: undefined, 58 63 [Nux.AgeAssuranceDismissibleFeedBanner]: undefined, 59 64 [Nux.PolicyUpdate202508]: undefined, 65 + [Nux.BookmarksAnnouncement]: undefined, 60 66 }
+7 -1
src/view/com/post/Post.tsx
··· 43 43 showReplyLine, 44 44 hideTopBorder, 45 45 style, 46 + onBeforePress, 46 47 }: { 47 48 post: AppBskyFeedDefs.PostView 48 49 showReplyLine?: boolean 49 50 hideTopBorder?: boolean 50 51 style?: StyleProp<ViewStyle> 52 + onBeforePress?: () => void 51 53 }) { 52 54 const moderationOpts = useModerationOpts() 53 55 const record = useMemo<AppBskyFeedPost.Record | undefined>( ··· 85 87 showReplyLine={showReplyLine} 86 88 hideTopBorder={hideTopBorder} 87 89 style={style} 90 + onBeforePress={onBeforePress} 88 91 /> 89 92 ) 90 93 } ··· 99 102 showReplyLine, 100 103 hideTopBorder, 101 104 style, 105 + onBeforePress: outerOnBeforePress, 102 106 }: { 103 107 post: Shadow<AppBskyFeedDefs.PostView> 104 108 record: AppBskyFeedPost.Record ··· 107 111 showReplyLine?: boolean 108 112 hideTopBorder?: boolean 109 113 style?: StyleProp<ViewStyle> 114 + onBeforePress?: () => void 110 115 }) { 111 116 const queryClient = useQueryClient() 112 117 const pal = usePalette('default') ··· 142 147 143 148 const onBeforePress = useCallback(() => { 144 149 unstableCacheProfileView(queryClient, post.author) 145 - }, [queryClient, post.author]) 150 + outerOnBeforePress?.() 151 + }, [queryClient, post.author, outerOnBeforePress]) 146 152 147 153 const [hover, setHover] = useState(false) 148 154 return (
+37
src/view/shell/Drawer.tsx
··· 30 30 Bell_Filled_Corner0_Rounded as BellFilled, 31 31 Bell_Stroke2_Corner0_Rounded as Bell, 32 32 } from '#/components/icons/Bell' 33 + import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 33 34 import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList' 34 35 import { 35 36 Hashtag_Filled_Corner0_Rounded as HashtagFilled, ··· 150 151 isAtHome, 151 152 isAtSearch, 152 153 isAtFeeds, 154 + isAtBookmarks, 153 155 isAtNotifications, 154 156 isAtMyProfile, 155 157 isAtMessages, ··· 231 233 setDrawerOpen(false) 232 234 }, [navigation, setDrawerOpen]) 233 235 236 + const onPressBookmarks = React.useCallback(() => { 237 + navigation.navigate('Bookmarks') 238 + setDrawerOpen(false) 239 + }, [navigation, setDrawerOpen]) 240 + 234 241 const onPressSettings = React.useCallback(() => { 235 242 navigation.navigate('Settings') 236 243 setDrawerOpen(false) ··· 292 299 /> 293 300 <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} /> 294 301 <ListsMenuItem onPress={onPressLists} /> 302 + <BookmarksMenuItem 303 + isActive={isAtBookmarks} 304 + onPress={onPressBookmarks} 305 + /> 295 306 <ProfileMenuItem 296 307 isActive={isAtMyProfile} 297 308 onPress={onPressProfile} ··· 537 548 ) 538 549 } 539 550 ListsMenuItem = React.memo(ListsMenuItem) 551 + 552 + let BookmarksMenuItem = ({ 553 + isActive, 554 + onPress, 555 + }: { 556 + isActive: boolean 557 + onPress: () => void 558 + }): React.ReactNode => { 559 + const {_} = useLingui() 560 + const t = useTheme() 561 + 562 + return ( 563 + <MenuItem 564 + icon={ 565 + isActive ? ( 566 + <BookmarkFilled style={[t.atoms.text]} width={iconWidth} /> 567 + ) : ( 568 + <Bookmark style={[t.atoms.text]} width={iconWidth} /> 569 + ) 570 + } 571 + label={_(msg`Saved`)} 572 + onPress={onPress} 573 + /> 574 + ) 575 + } 576 + BookmarksMenuItem = React.memo(BookmarksMenuItem) 540 577 541 578 let ProfileMenuItem = ({ 542 579 isActive,
+19
src/view/shell/desktop/LeftNav.tsx
··· 40 40 Bell_Filled_Corner0_Rounded as BellFilled, 41 41 Bell_Stroke2_Corner0_Rounded as Bell, 42 42 } from '#/components/icons/Bell' 43 + import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 43 44 import { 44 45 BulletList_Filled_Corner0_Rounded as ListFilled, 45 46 BulletList_Stroke2_Corner0_Rounded as List, ··· 742 743 /> 743 744 } 744 745 label={_(msg`Lists`)} 746 + /> 747 + <NavItem 748 + href="/saved" 749 + icon={ 750 + <Bookmark 751 + style={pal.text} 752 + aria-hidden={true} 753 + width={NAV_ICON_WIDTH} 754 + /> 755 + } 756 + iconFilled={ 757 + <BookmarkFilled 758 + style={pal.text} 759 + aria-hidden={true} 760 + width={NAV_ICON_WIDTH} 761 + /> 762 + } 763 + label={_(msg`Saved`)} 745 764 /> 746 765 <NavItem 747 766 href={currentAccount ? makeProfileLink(currentAccount) : '/'}
+35 -30
yarn.lock
··· 63 63 "@atproto/xrpc" "^0.7.3" 64 64 "@atproto/xrpc-server" "^0.9.3" 65 65 66 - "@atproto/api@^0.16.2": 67 - version "0.16.2" 68 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.2.tgz#1b2870e9a03d88f00a27602281755fa82ec824dd" 69 - integrity sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ== 66 + "@atproto/api@^0.16.4": 67 + version "0.16.4" 68 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.4.tgz#952071aca39a731b1664dc3ea4385fa2fb8e4c62" 69 + integrity sha512-beAOh0C7uH2F3/BUDUV6lHvxuwRPp+afIneWA9+8iDgkNV2JFuIm769FcjYQ0slXyJ21PxI0IDfOs6Jqtu72Xw== 70 70 dependencies: 71 71 "@atproto/common-web" "^0.4.2" 72 - "@atproto/lexicon" "^0.4.12" 72 + "@atproto/lexicon" "^0.4.14" 73 73 "@atproto/syntax" "^0.4.0" 74 - "@atproto/xrpc" "^0.7.1" 74 + "@atproto/xrpc" "^0.7.3" 75 75 await-lock "^2.2.2" 76 76 multiformats "^9.9.0" 77 77 tlds "^1.234.0" 78 78 zod "^3.23.8" 79 79 80 - "@atproto/api@^0.16.4": 81 - version "0.16.4" 82 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.4.tgz#952071aca39a731b1664dc3ea4385fa2fb8e4c62" 83 - integrity sha512-beAOh0C7uH2F3/BUDUV6lHvxuwRPp+afIneWA9+8iDgkNV2JFuIm769FcjYQ0slXyJ21PxI0IDfOs6Jqtu72Xw== 80 + "@atproto/api@^0.16.7": 81 + version "0.16.7" 82 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.7.tgz#eb0c520dbdaf74ba6f5ad7f9c6afe2d1389b8a0a" 83 + integrity sha512-EdVWkEgaEQm1LEiiP1fW/XXXpMNmtvT5c9+cZVRiwYc4rTB66WIJJWqmaMT/tB7nccMkFjr6FtwObq5LewWfgw== 84 84 dependencies: 85 85 "@atproto/common-web" "^0.4.2" 86 - "@atproto/lexicon" "^0.4.14" 87 - "@atproto/syntax" "^0.4.0" 88 - "@atproto/xrpc" "^0.7.3" 86 + "@atproto/lexicon" "^0.5.0" 87 + "@atproto/syntax" "^0.4.1" 88 + "@atproto/xrpc" "^0.7.4" 89 89 await-lock "^2.2.2" 90 90 multiformats "^9.9.0" 91 91 tlds "^1.234.0" ··· 293 293 multiformats "^9.9.0" 294 294 zod "^3.23.8" 295 295 296 - "@atproto/lexicon@^0.4.12": 297 - version "0.4.12" 298 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.12.tgz#89a704789d983f8405a52095769b5b58d87f5af7" 299 - integrity sha512-fcEvEQ1GpQYF5igZ4IZjPWEoWVpsEF22L9RexxLS3ptfySXLflEyH384e7HITzO/73McDeaJx3lqHIuqn9ulnw== 296 + "@atproto/lexicon@^0.4.14": 297 + version "0.4.14" 298 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.14.tgz#a2b5f2bb950d41e78d18f276a01d71b5d89183d8" 299 + integrity sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ== 300 300 dependencies: 301 301 "@atproto/common-web" "^0.4.2" 302 302 "@atproto/syntax" "^0.4.0" ··· 304 304 multiformats "^9.9.0" 305 305 zod "^3.23.8" 306 306 307 - "@atproto/lexicon@^0.4.14": 308 - version "0.4.14" 309 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.14.tgz#a2b5f2bb950d41e78d18f276a01d71b5d89183d8" 310 - integrity sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ== 307 + "@atproto/lexicon@^0.5.0": 308 + version "0.5.0" 309 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.5.0.tgz#4d2be425361f9ac7f9754b8a1ccba29ddf0b9460" 310 + integrity sha512-3aAzEAy9EAPs3CxznzMhEcqDd7m3vz1eze/ya9/ThbB7yleqJIhz5GY2q76tCCwHPhn5qDDMhlA9kKV6fG23gA== 311 311 dependencies: 312 312 "@atproto/common-web" "^0.4.2" 313 - "@atproto/syntax" "^0.4.0" 313 + "@atproto/syntax" "^0.4.1" 314 314 iso-datestring-validator "^2.2.2" 315 315 multiformats "^9.9.0" 316 316 zod "^3.23.8" ··· 495 495 resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2" 496 496 integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA== 497 497 498 + "@atproto/syntax@^0.4.1": 499 + version "0.4.1" 500 + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.1.tgz#f77bc610ae0914449ff3f4731861e3da429915f5" 501 + integrity sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw== 502 + 498 503 "@atproto/xrpc-server@^0.9.3": 499 504 version "0.9.3" 500 505 resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.3.tgz#45877ca9432c61294b8b7b1ba7a2430add327f82" ··· 513 518 ws "^8.12.0" 514 519 zod "^3.23.8" 515 520 516 - "@atproto/xrpc@^0.7.1": 517 - version "0.7.1" 518 - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.1.tgz#51a8fc131eb21bd1229129d0a46384accc50ad65" 519 - integrity sha512-ANHEzlskYlMEdH18m+Itp3a8d0pEJao2qoDybDoMupTnoeNkya4VKIaOgAi6ERQnqatBBZyn9asW+7rJmSt/8g== 520 - dependencies: 521 - "@atproto/lexicon" "^0.4.12" 522 - zod "^3.23.8" 523 - 524 521 "@atproto/xrpc@^0.7.3": 525 522 version "0.7.3" 526 523 resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.3.tgz#e93692326b765426e1e2cca811a668fb7d67303c" 527 524 integrity sha512-JaJbZ4ymIJzOakR3B/B+6NyppW3oQWn06OtQq03LqVsu93Afpc8VkNtPN3QnhQcD/yYSYCu73lLsDM/ErJEk7Q== 528 525 dependencies: 529 526 "@atproto/lexicon" "^0.4.14" 527 + zod "^3.23.8" 528 + 529 + "@atproto/xrpc@^0.7.4": 530 + version "0.7.4" 531 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.4.tgz#030342548797c1f344968c457a8659dbb60a2d60" 532 + integrity sha512-sDi68+QE1XHegTaNAndlX41Gp827pouSzSs8CyAwhrqZdsJUxE3P7TMtrA0z+zAjvxVyvzscRc0TsN/fGUGrhw== 533 + dependencies: 534 + "@atproto/lexicon" "^0.5.0" 530 535 zod "^3.23.8" 531 536 532 537 "@aws-crypto/crc32@3.0.0":