diff --git a/src/screens/Bookmarks.tsx b/src/screens/Bookmarks.tsx index 47ed719dec..edc44493a5 100644 --- a/src/screens/Bookmarks.tsx +++ b/src/screens/Bookmarks.tsx @@ -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 export function BookmarksScreen({}: Props) { - const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( - React.useCallback(() => { + useCallback(() => { setMinimalShellMode(false) }, [setMinimalShellMode]), ) @@ -33,6 +53,214 @@ export function BookmarksScreen({}: Props) { + ) } + +type ListItem = + | { + type: 'loading' + key: 'loading' + } + | { + type: 'bookmark' + key: string + bookmark: Omit & { + item: $Typed + } + } + | { + type: 'bookmarkNotFound' + key: string + bookmark: Omit & { + item: $Typed + } + } + +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, + }, + }) + } + if (AppBskyFeedDefs.isPostView(bookmark.item)) { + i.push({ + type: 'bookmark', + key: bookmark.item.uri, + bookmark: { + ...bookmark, + item: bookmark.item as $Typed, + }, + }) + } + } + } + + return i + }, [isLoading, error, data]) + + return ( + + } + initialNumToRender={initialNumToRender} + windowSize={9} + maxToRenderPerBatch={isIOS ? 5 : 1} + updateCellsBatchingPeriod={40} + sideBorders={false} + /> + ) +} + +function BookmarkNotFound({ + hideTopBorder, + post, +}: { + hideTopBorder: boolean + post: $Typed +}) { + 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 ( + + + + + + + + + + + + This post was deleted by its author + + + + + ) +} + +function renderItem({item, index}: {item: ListItem; index: number}) { + switch (item.type) { + case 'loading': { + return + } + case 'bookmark': { + return + } + case 'bookmarkNotFound': { + return ( + + ) + } + default: + return null + } +} + +const keyExtractor = (item: ListItem) => item.key diff --git a/src/state/queries/bookmarks/useBookmarkMutation.ts b/src/state/queries/bookmarks/useBookmarkMutation.ts index 467e7f4dda..374405102a 100644 --- a/src/state/queries/bookmarks/useBookmarkMutation.ts +++ b/src/state/queries/bookmarks/useBookmarkMutation.ts @@ -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}) diff --git a/src/state/queries/bookmarks/useBookmarksQuery.ts b/src/state/queries/bookmarks/useBookmarksQuery.ts new file mode 100644 index 0000000000..d4f3a567a8 --- /dev/null +++ b/src/state/queries/bookmarks/useBookmarksQuery.ts @@ -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, + 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( + b.item, + AppBskyFeedDefs.isPostView, + ) + ) { + const shadow = dangerousGetPostShadow(b.item) + if (shadow && !shadow.bookmarked) return false + } + return true + }), + } + }), + } + }, + }) +}