forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {
4 type $Typed,
5 type AppBskyBookmarkDefs,
6 AppBskyFeedDefs,
7} from '@atproto/api'
8import {msg} from '@lingui/core/macro'
9import {useLingui} from '@lingui/react'
10import {Trans} from '@lingui/react/macro'
11import {
12 type NavigationProp,
13 useFocusEffect,
14 useNavigation,
15} from '@react-navigation/native'
16
17import {useCleanError} from '#/lib/hooks/useCleanError'
18import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
19import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
20import {
21 type CommonNavigatorParams,
22 type NativeStackScreenProps,
23} from '#/lib/routes/types'
24import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
25import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery'
26import {useSetMinimalShellMode} from '#/state/shell'
27import {Post} from '#/view/com/post/Post'
28import {EmptyState} from '#/view/com/util/EmptyState'
29import {List} from '#/view/com/util/List'
30import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
31import {atoms as a, useTheme} from '#/alf'
32import {Button, ButtonIcon, ButtonText} from '#/components/Button'
33import {BookmarkDeleteLarge, BookmarkFilled} from '#/components/icons/Bookmark'
34import {CircleQuestion_Stroke2_Corner2_Rounded as QuestionIcon} from '#/components/icons/CircleQuestion'
35import * as Layout from '#/components/Layout'
36import {ListFooter} from '#/components/Lists'
37import * as Skele from '#/components/Skeleton'
38import * as toast from '#/components/Toast'
39import {Text} from '#/components/Typography'
40import {useAnalytics} from '#/analytics'
41import {IS_IOS} from '#/env'
42
43type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'>
44
45export function BookmarksScreen({}: Props) {
46 const setMinimalShellMode = useSetMinimalShellMode()
47 const ax = useAnalytics()
48
49 useFocusEffect(
50 useCallback(() => {
51 setMinimalShellMode(false)
52 ax.metric('bookmarks:view', {})
53 }, [setMinimalShellMode, ax]),
54 )
55
56 return (
57 <Layout.Screen testID="bookmarksScreen">
58 <Layout.Header.Outer>
59 <Layout.Header.BackButton />
60 <Layout.Header.Content>
61 <Layout.Header.TitleText>
62 <Trans>Saved Posts</Trans>
63 </Layout.Header.TitleText>
64 </Layout.Header.Content>
65 <Layout.Header.Slot />
66 </Layout.Header.Outer>
67 <BookmarksInner />
68 </Layout.Screen>
69 )
70}
71
72type ListItem =
73 | {
74 type: 'loading'
75 key: 'loading'
76 }
77 | {
78 type: 'empty'
79 key: 'empty'
80 }
81 | {
82 type: 'bookmark'
83 key: string
84 bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & {
85 item: $Typed<AppBskyFeedDefs.PostView>
86 }
87 }
88 | {
89 type: 'bookmarkNotFound'
90 key: string
91 bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & {
92 item: $Typed<AppBskyFeedDefs.NotFoundPost>
93 }
94 }
95
96function BookmarksInner() {
97 const initialNumToRender = useInitialNumToRender()
98 const cleanError = useCleanError()
99 const [isPTRing, setIsPTRing] = useState(false)
100 const trackPostView = usePostViewTracking('Bookmarks')
101 const {
102 data,
103 isLoading,
104 isFetchingNextPage,
105 hasNextPage,
106 fetchNextPage,
107 error,
108 refetch,
109 } = useBookmarksQuery()
110 const cleanedError = useMemo(() => {
111 const {raw, clean} = cleanError(error)
112 return clean || raw
113 }, [error, cleanError])
114
115 const onRefresh = useCallback(async () => {
116 setIsPTRing(true)
117 try {
118 await refetch()
119 } finally {
120 setIsPTRing(false)
121 }
122 }, [refetch, setIsPTRing])
123
124 const onEndReached = useCallback(async () => {
125 if (isFetchingNextPage || !hasNextPage || error) return
126 try {
127 await fetchNextPage()
128 } catch {}
129 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
130
131 const items = useMemo(() => {
132 const i: ListItem[] = []
133
134 if (isLoading) {
135 i.push({type: 'loading', key: 'loading'})
136 } else if (error || !data) {
137 // handled in Footer
138 } else {
139 const bookmarks = data.pages.flatMap(p => p.bookmarks)
140
141 if (bookmarks.length > 0) {
142 for (const bookmark of bookmarks) {
143 if (AppBskyFeedDefs.isNotFoundPost(bookmark.item)) {
144 i.push({
145 type: 'bookmarkNotFound',
146 key: bookmark.item.uri,
147 bookmark: {
148 ...bookmark,
149 item: bookmark.item,
150 },
151 })
152 }
153 if (AppBskyFeedDefs.isPostView(bookmark.item)) {
154 i.push({
155 type: 'bookmark',
156 key: bookmark.item.uri,
157 bookmark: {
158 ...bookmark,
159 item: bookmark.item,
160 },
161 })
162 }
163 }
164 } else {
165 i.push({type: 'empty', key: 'empty'})
166 }
167 }
168
169 return i
170 }, [isLoading, error, data])
171
172 const isEmpty = items.length === 1 && items[0]?.type === 'empty'
173
174 return (
175 <List
176 data={items}
177 renderItem={renderItem}
178 keyExtractor={keyExtractor}
179 refreshing={isPTRing}
180 onRefresh={onRefresh}
181 onEndReached={onEndReached}
182 onEndReachedThreshold={4}
183 onItemSeen={item => {
184 if (item.type === 'bookmark') {
185 trackPostView(item.bookmark.item)
186 }
187 }}
188 ListFooterComponent={
189 <ListFooter
190 isFetchingNextPage={isFetchingNextPage}
191 error={cleanedError}
192 onRetry={fetchNextPage}
193 style={[isEmpty && a.border_t_0]}
194 />
195 }
196 initialNumToRender={initialNumToRender}
197 windowSize={9}
198 maxToRenderPerBatch={IS_IOS ? 5 : 1}
199 updateCellsBatchingPeriod={40}
200 sideBorders={false}
201 />
202 )
203}
204
205function BookmarkNotFound({
206 hideTopBorder,
207 post,
208}: {
209 hideTopBorder: boolean
210 post: $Typed<AppBskyFeedDefs.NotFoundPost>
211}) {
212 const t = useTheme()
213 const {_} = useLingui()
214 const {mutateAsync: bookmark} = useBookmarkMutation()
215 const cleanError = useCleanError()
216
217 const remove = async () => {
218 try {
219 await bookmark({action: 'delete', uri: post.uri})
220 toast.show(_(msg`Removed from saved posts`), {
221 type: 'info',
222 })
223 } catch (e: any) {
224 const {raw, clean} = cleanError(e)
225 toast.show(clean || raw || e, {
226 type: 'error',
227 })
228 }
229 }
230
231 return (
232 <View
233 style={[
234 a.flex_row,
235 a.align_start,
236 a.px_xl,
237 a.py_lg,
238 a.gap_sm,
239 !hideTopBorder && a.border_t,
240 t.atoms.border_contrast_low,
241 ]}>
242 <Skele.Circle size={42}>
243 <QuestionIcon size="lg" fill={t.atoms.text_contrast_low.color} />
244 </Skele.Circle>
245 <View style={[a.flex_1, a.gap_2xs]}>
246 <View style={[a.flex_row, a.gap_xs]}>
247 <Skele.Text style={[a.text_md, {width: 80}]} />
248 <Skele.Text style={[a.text_md, {width: 100}]} />
249 </View>
250
251 <Text
252 style={[
253 a.text_md,
254 a.leading_snug,
255 a.italic,
256 t.atoms.text_contrast_medium,
257 ]}>
258 <Trans>This post was deleted by its author</Trans>
259 </Text>
260 </View>
261 <Button
262 label={_(msg`Remove from saved posts`)}
263 size="tiny"
264 color="secondary"
265 onPress={remove}>
266 <ButtonIcon icon={BookmarkFilled} />
267 <ButtonText>
268 <Trans>Remove</Trans>
269 </ButtonText>
270 </Button>
271 </View>
272 )
273}
274
275function BookmarkItem({
276 item,
277 hideTopBorder,
278}: {
279 item: Extract<ListItem, {type: 'bookmark'}>
280 hideTopBorder: boolean
281}) {
282 const ax = useAnalytics()
283 return (
284 <Post
285 post={item.bookmark.item}
286 hideTopBorder={hideTopBorder}
287 onBeforePress={() => {
288 ax.metric('bookmarks:post-clicked', {})
289 }}
290 />
291 )
292}
293
294function BookmarksEmpty() {
295 const t = useTheme()
296 const {_} = useLingui()
297 const navigation = useNavigation<NavigationProp<CommonNavigatorParams>>()
298
299 return (
300 <EmptyState
301 icon={BookmarkDeleteLarge}
302 message={_(msg`Nothing saved yet`)}
303 textStyle={[t.atoms.text_contrast_medium, a.font_medium]}
304 button={{
305 label: _(msg`Button to go back to the home timeline`),
306 text: _(msg`Go home`),
307 onPress: () => navigation.navigate('Home' as never),
308 size: 'small',
309 color: 'secondary',
310 }}
311 style={[a.pt_3xl]}
312 />
313 )
314}
315
316function renderItem({item, index}: {item: ListItem; index: number}) {
317 switch (item.type) {
318 case 'loading': {
319 return <PostFeedLoadingPlaceholder />
320 }
321 case 'empty': {
322 return <BookmarksEmpty />
323 }
324 case 'bookmark': {
325 return <BookmarkItem item={item} hideTopBorder={index === 0} />
326 }
327 case 'bookmarkNotFound': {
328 return (
329 <BookmarkNotFound
330 post={item.bookmark.item}
331 hideTopBorder={index === 0}
332 />
333 )
334 }
335 default:
336 return null
337 }
338}
339
340const keyExtractor = (item: ListItem) => item.key