Bluesky app fork with some witchin' additions 馃挮
at linkat-integration 339 lines 9.0 kB view raw
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