📓 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>
This commit is contained in:
Eric Bailey
2025-09-04 17:30:15 -05:00
committed by GitHub
parent 04b869714e
commit 535d4d6cf7
38 changed files with 1251 additions and 163 deletions
@@ -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>
)
}
+294
View File
@@ -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
@@ -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
@@ -368,6 +368,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
</View>
)}
<PostControls
variant="compact"
post={postShadow}
record={record}
richText={richText}