📓 Bookmarks (#8976)
* Add button to controls, respace * Hook up shadow and mutation * Add Bookmarks screen * Build out Bookmarks screen * Handle removals via shadow * Use truncateAndInvalidate strategy * Add empty state * Add toasts * Add undo buttons to toasts * Stage NUX, needs image * Finesse post controls * New reply icon * Use curvier variant of repost icon * Prevent layout shift with align_start * Update api pkg * Swap in new image * Limit spacing on desktop * Rm decimals over 10k * Better optimistic adding/removing * Add metrics * Comment * Remove unused code block * Remove debug limit * Fork shadow for web/native * Tweak alt * add preventExpansion: true * Refine hitslop * Add count to anchor * Reduce space in compact mode --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -368,6 +368,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
||||
</View>
|
||||
)}
|
||||
<PostControls
|
||||
variant="compact"
|
||||
post={postShadow}
|
||||
record={record}
|
||||
richText={richText}
|
||||
|
||||
Reference in New Issue
Block a user