diff options
Diffstat (limited to 'src/screens/Bookmarks')
-rw-r--r-- | src/screens/Bookmarks/components/EmptyState.tsx | 59 | ||||
-rw-r--r-- | src/screens/Bookmarks/index.tsx | 294 |
2 files changed, 353 insertions, 0 deletions
diff --git a/src/screens/Bookmarks/components/EmptyState.tsx b/src/screens/Bookmarks/components/EmptyState.tsx new file mode 100644 index 000000000..bfd80903d --- /dev/null +++ b/src/screens/Bookmarks/components/EmptyState.tsx @@ -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> + ) +} diff --git a/src/screens/Bookmarks/index.tsx b/src/screens/Bookmarks/index.tsx new file mode 100644 index 000000000..72ad1f167 --- /dev/null +++ b/src/screens/Bookmarks/index.tsx @@ -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 |