diff options
Diffstat (limited to 'src/screens')
-rw-r--r-- | src/screens/Bookmarks/components/EmptyState.tsx | 59 | ||||
-rw-r--r-- | src/screens/Bookmarks/index.tsx | 294 | ||||
-rw-r--r-- | src/screens/PostThread/components/ThreadItemAnchor.tsx | 33 | ||||
-rw-r--r-- | src/screens/PostThread/components/ThreadItemTreePost.tsx | 1 |
4 files changed, 381 insertions, 6 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 diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index b59397b0b..08dd272f7 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -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 diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx index b009695a9..5d3123358 100644 --- a/src/screens/PostThread/components/ThreadItemTreePost.tsx +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -368,6 +368,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ </View> )} <PostControls + variant="compact" post={postShadow} record={record} richText={richText} |