Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a876aae44ea07494ebea9727350aa060b81f317b 218 lines 6.4 kB view raw
1import {useCallback, useEffect, useMemo, useState} from 'react' 2import { 3 ActivityIndicator, 4 type ListRenderItemInfo, 5 StyleSheet, 6 View, 7} from 'react-native' 8import {msg} from '@lingui/core/macro' 9import {useLingui} from '@lingui/react' 10 11import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 12import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 13import {cleanError} from '#/lib/strings/errors' 14import {s} from '#/lib/styles' 15import {logger} from '#/logger' 16import {useModerationOpts} from '#/state/preferences/moderation-opts' 17import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' 18import {EmptyState} from '#/view/com/util/EmptyState' 19import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 20import {List, type ListProps, type ListRef} from '#/view/com/util/List' 21import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 22import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 23import {useTheme} from '#/alf' 24import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell' 25import {NotificationFeedItem} from './NotificationFeedItem' 26 27const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} 28const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} 29const LOADING_ITEM = {_reactKey: '__loading__'} 30 31export function NotificationFeed({ 32 filter, 33 enabled, 34 scrollElRef, 35 onPressTryAgain, 36 onScrolledDownChange, 37 ListHeaderComponent, 38 refreshNotifications, 39}: { 40 filter: 'all' | 'mentions' 41 enabled: boolean 42 scrollElRef?: ListRef 43 onPressTryAgain?: () => void 44 onScrolledDownChange: (isScrolledDown: boolean) => void 45 ListHeaderComponent?: ListProps['ListHeaderComponent'] 46 refreshNotifications: () => Promise<void> 47}) { 48 const initialNumToRender = useInitialNumToRender() 49 const [isPTRing, setIsPTRing] = useState(false) 50 const t = useTheme() 51 const {_} = useLingui() 52 const moderationOpts = useModerationOpts() 53 const trackPostView = usePostViewTracking('Notifications') 54 const { 55 data, 56 isFetching, 57 isFetched, 58 isError, 59 error, 60 hasNextPage, 61 isFetchingNextPage, 62 fetchNextPage, 63 } = useNotificationFeedQuery({ 64 enabled: enabled && !!moderationOpts, 65 filter, 66 }) 67 // previously, this was `!isFetching && !data?.pages[0]?.items.length` 68 // however, if the first page had no items (can happen in the mentions tab!) 69 // it would flicker the empty state whenever it was loading. 70 // therefore, we need to find if *any* page has items. in 99.9% of cases, 71 // the `.find()` won't need to go any further than the first page -sfn 72 const isEmpty = 73 !isFetching && !data?.pages.find(page => page.items.length > 0) 74 75 const items = useMemo(() => { 76 let arr: any[] = [] 77 if (isFetched) { 78 if (isEmpty) { 79 arr = arr.concat([EMPTY_FEED_ITEM]) 80 } else if (data) { 81 for (const page of data?.pages) { 82 arr = arr.concat(page.items) 83 } 84 } 85 if (isError && !isEmpty) { 86 arr = arr.concat([LOAD_MORE_ERROR_ITEM]) 87 } 88 } else { 89 arr.push(LOADING_ITEM) 90 } 91 return arr 92 }, [isFetched, isError, isEmpty, data]) 93 94 const onRefresh = useCallback(async () => { 95 try { 96 setIsPTRing(true) 97 await refreshNotifications() 98 } catch (err) { 99 logger.error('Failed to refresh notifications feed', { 100 message: err, 101 }) 102 } finally { 103 setIsPTRing(false) 104 } 105 }, [refreshNotifications, setIsPTRing]) 106 107 const onEndReached = useCallback(async () => { 108 if (isFetching || !hasNextPage || isError) return 109 110 try { 111 await fetchNextPage() 112 } catch (err) { 113 logger.error('Failed to load more notifications', {message: err}) 114 } 115 }, [isFetching, hasNextPage, isError, fetchNextPage]) 116 117 const onPressRetryLoadMore = useCallback(() => { 118 fetchNextPage() 119 }, [fetchNextPage]) 120 121 const renderItem = useCallback( 122 ({item, index}: ListRenderItemInfo<any>) => { 123 if (item === EMPTY_FEED_ITEM) { 124 return ( 125 <EmptyState 126 icon={BellIcon} 127 message={_(msg`No notifications yet!`)} 128 style={styles.emptyState} 129 /> 130 ) 131 } else if (item === LOAD_MORE_ERROR_ITEM) { 132 return ( 133 <LoadMoreRetryBtn 134 label={_( 135 msg`There was an issue fetching notifications. Tap here to try again.`, 136 )} 137 onPress={onPressRetryLoadMore} 138 /> 139 ) 140 } else if (item === LOADING_ITEM) { 141 return <NotificationFeedLoadingPlaceholder /> 142 } 143 return ( 144 <NotificationFeedItem 145 highlightUnread={filter === 'all'} 146 item={item} 147 moderationOpts={moderationOpts!} 148 hideTopBorder={index === 0} 149 /> 150 ) 151 }, 152 [moderationOpts, _, onPressRetryLoadMore, filter], 153 ) 154 155 const FeedFooter = useCallback( 156 () => 157 isFetchingNextPage ? ( 158 <View style={styles.feedFooter}> 159 <ActivityIndicator color={t.palette.primary_500} /> 160 </View> 161 ) : ( 162 <View /> 163 ), 164 [isFetchingNextPage, t.palette.primary_500], 165 ) 166 167 useEffect(() => { 168 if (!enabled) { 169 setIsPTRing(false) 170 } 171 }, [enabled]) 172 173 return ( 174 <View style={s.hContentRegion}> 175 {error && ( 176 <ErrorMessage 177 message={cleanError(error)} 178 onPressTryAgain={onPressTryAgain} 179 /> 180 )} 181 <List 182 testID="notifsFeed" 183 ref={scrollElRef} 184 data={items} 185 keyExtractor={item => item._reactKey} 186 renderItem={renderItem} 187 ListHeaderComponent={ListHeaderComponent} 188 ListFooterComponent={FeedFooter} 189 refreshing={isPTRing} 190 onRefresh={onRefresh} 191 onEndReached={onEndReached} 192 onEndReachedThreshold={2} 193 onScrolledDownChange={onScrolledDownChange} 194 onItemSeen={item => { 195 if ( 196 (item.type === 'reply' || 197 item.type === 'mention' || 198 item.type === 'quote') && 199 item.subject 200 ) { 201 trackPostView(item.subject) 202 } 203 }} 204 contentContainerStyle={s.contentContainer} 205 desktopFixedHeight 206 initialNumToRender={initialNumToRender} 207 windowSize={11} 208 sideBorders={false} 209 removeClippedSubviews={true} 210 /> 211 </View> 212 ) 213} 214 215const styles = StyleSheet.create({ 216 feedFooter: {paddingTop: 20}, 217 emptyState: {paddingVertical: 40}, 218})