Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import React 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] = React.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 = React.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 = React.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 = React.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 = React.useCallback(() => {
118 fetchNextPage()
119 }, [fetchNextPage])
120
121 const renderItem = React.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 = React.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 React.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})