Bluesky app fork with some witchin' additions 馃挮
at feat/markdown-basic 340 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} 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