Build out Bookmarks screen
This commit is contained in:
+232
-4
@@ -1,23 +1,43 @@
|
|||||||
import React from 'react'
|
import {useCallback, useMemo, useState} from 'react'
|
||||||
import {Trans} from '@lingui/macro'
|
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 {useLingui} from '@lingui/react'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||||
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {
|
import {
|
||||||
type CommonNavigatorParams,
|
type CommonNavigatorParams,
|
||||||
type NativeStackScreenProps,
|
type NativeStackScreenProps,
|
||||||
} from '#/lib/routes/types'
|
} 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 {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 * 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'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'>
|
||||||
|
|
||||||
export function BookmarksScreen({}: Props) {
|
export function BookmarksScreen({}: Props) {
|
||||||
const {_} = useLingui()
|
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
useCallback(() => {
|
||||||
setMinimalShellMode(false)
|
setMinimalShellMode(false)
|
||||||
}, [setMinimalShellMode]),
|
}, [setMinimalShellMode]),
|
||||||
)
|
)
|
||||||
@@ -33,6 +53,214 @@ export function BookmarksScreen({}: Props) {
|
|||||||
</Layout.Header.Content>
|
</Layout.Header.Content>
|
||||||
<Layout.Header.Slot />
|
<Layout.Header.Slot />
|
||||||
</Layout.Header.Outer>
|
</Layout.Header.Outer>
|
||||||
|
<BookmarksInner />
|
||||||
</Layout.Screen>
|
</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 {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {updatePostShadow} from '#/state/cache/post-shadow'
|
import {updatePostShadow} from '#/state/cache/post-shadow'
|
||||||
|
import {bookmarksQueryKeyRoot} from '#/state/queries/bookmarks/useBookmarksQuery'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
type MutationArgs =
|
type MutationArgs =
|
||||||
@@ -28,6 +29,9 @@ export function useBookmarkMutation() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSuccess() {
|
||||||
|
qc.invalidateQueries({queryKey: [bookmarksQueryKeyRoot]})
|
||||||
|
},
|
||||||
onError(e, args) {
|
onError(e, args) {
|
||||||
if (args.action === 'create') {
|
if (args.action === 'create') {
|
||||||
updatePostShadow(qc, args.uri, {bookmarked: false})
|
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
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user