Build out Bookmarks screen

This commit is contained in:
Eric Bailey
2025-08-25 16:40:19 -05:00
parent ea29a3509e
commit 8c40895fd5
3 changed files with 293 additions and 4 deletions
+232 -4
View File
@@ -1,23 +1,43 @@
import React from 'react'
import {Trans} from '@lingui/macro'
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 {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 {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 {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'>
export function BookmarksScreen({}: Props) {
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
useFocusEffect(
React.useCallback(() => {
useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
@@ -33,6 +53,214 @@ export function BookmarksScreen({}: Props) {
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<BookmarksInner />
</Layout.Screen>
)
}
type ListItem =
| {
type: 'loading'
key: 'loading'
}
| {
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 {
console.log('Fetching more bookmarks...')
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)
for (const bookmark of bookmarks) {
if (AppBskyFeedDefs.isBlockedPost(bookmark.item)) return null
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>,
},
})
}
}
}
return i
}, [isLoading, error, data])
return (
<List
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
onEndReachedThreshold={4}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanedError}
onRetry={fetchNextPage}
/>
}
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 = useCallback(async () => {
try {
await bookmark({action: 'delete', uri: post.uri})
} catch (e) {
const {raw, clean} = cleanError(e)
console.log(clean || raw || e)
// TODO toast
}
}, [post.uri, bookmark, cleanError])
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 this post from your bookmarks`)}
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 'bookmark': {
return <Post post={item.bookmark.item} hideTopBorder={index === 0} />
}
case 'bookmarkNotFound': {
return (
<BookmarkNotFound
post={item.bookmark.item}
hideTopBorder={index === 0}
/>
)
}
default:
return null
}
}
const keyExtractor = (item: ListItem) => item.key
@@ -3,6 +3,7 @@ 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 {bookmarksQueryKeyRoot} from '#/state/queries/bookmarks/useBookmarksQuery'
import {useAgent} from '#/state/session'
type MutationArgs =
@@ -28,6 +29,9 @@ export function useBookmarkMutation() {
})
}
},
onSuccess() {
qc.invalidateQueries({queryKey: [bookmarksQueryKeyRoot]})
},
onError(e, args) {
if (args.action === 'create') {
updatePostShadow(qc, args.uri, {bookmarked: false})
@@ -0,0 +1,57 @@
import {type AppBskyBookmarkGetBookmarks, AppBskyFeedDefs} from '@atproto/api'
import {
type InfiniteData,
type QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query'
import {dangerousGetPostShadow} from '#/state/cache/post-shadow'
import {useAgent} from '#/state/session'
import * as bsky from '#/types/bsky'
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,
select: data => {
return {
...data,
pages: data.pages.map(page => {
return {
...page,
bookmarks: page.bookmarks.filter(b => {
if (
bsky.dangerousIsType<AppBskyFeedDefs.PostView>(
b.item,
AppBskyFeedDefs.isPostView,
)
) {
const shadow = dangerousGetPostShadow(b.item)
if (shadow && !shadow.bookmarked) return false
}
return true
}),
}
}),
}
},
})
}