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