forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2import {View} from 'react-native'
3import {msg} from '@lingui/core/macro'
4import {useLingui} from '@lingui/react'
5import {Trans} from '@lingui/react/macro'
6import {useFocusEffect, useIsFocused} from '@react-navigation/native'
7import {useQueryClient} from '@tanstack/react-query'
8
9import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
10import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
11import {ComposeIcon2} from '#/lib/icons'
12import {
13 type NativeStackScreenProps,
14 type NotificationsTabNavigatorParams,
15} from '#/lib/routes/types'
16import {s} from '#/lib/styles'
17import {logger} from '#/logger'
18import {emitSoftReset, listenSoftReset} from '#/state/events'
19import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
20import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
21import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
22import {
23 useUnreadNotifications,
24 useUnreadNotificationsApi,
25} from '#/state/queries/notifications/unread'
26import {truncateAndInvalidate} from '#/state/queries/util'
27import {NotificationFeed} from '#/view/com/notifications/NotificationFeed'
28import {Pager} from '#/view/com/pager/Pager'
29import {TabBar} from '#/view/com/pager/TabBar'
30import {FAB} from '#/view/com/util/fab/FAB'
31import {type ListMethods} from '#/view/com/util/List'
32import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
33import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
34import {atoms as a, useTheme, web} from '#/alf'
35import {Admonition} from '#/components/Admonition'
36import {ButtonIcon} from '#/components/Button'
37import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
38import * as Layout from '#/components/Layout'
39import {InlineLinkText, Link} from '#/components/Link'
40import {Loader} from '#/components/Loader'
41import {IS_NATIVE} from '#/env'
42
43// We don't currently persist this across reloads since
44// you gotta visit All to clear the badge anyway.
45// But let's at least persist it during the sesssion.
46let lastActiveTab = 0
47
48type Props = NativeStackScreenProps<
49 NotificationsTabNavigatorParams,
50 'Notifications'
51>
52export function NotificationsScreen({}: Props) {
53 const {_} = useLingui()
54 const {openComposer} = useOpenComposer()
55 const unreadNotifs = useUnreadNotifications()
56 const hasNew = !!unreadNotifs
57 const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi()
58 const [isLoadingAll, setIsLoadingAll] = useState(false)
59 const [isLoadingMentions, setIsLoadingMentions] = useState(false)
60 const initialActiveTab = lastActiveTab
61 const [activeTab, setActiveTab] = useState(initialActiveTab)
62 const isLoading = activeTab === 0 ? isLoadingAll : isLoadingMentions
63
64 const enableSquareButtons = useEnableSquareButtons()
65
66 const onPageSelected = useCallback(
67 (index: number) => {
68 setActiveTab(index)
69 lastActiveTab = index
70 },
71 [setActiveTab],
72 )
73
74 const queryClient = useQueryClient()
75 const checkUnreadMentions = useCallback(
76 async ({invalidate}: {invalidate: boolean}) => {
77 if (invalidate) {
78 return truncateAndInvalidate(queryClient, NOTIFS_RQKEY('mentions'))
79 } else {
80 // Background polling is not implemented for the mentions tab.
81 // Just ignore it.
82 }
83 },
84 [queryClient],
85 )
86
87 const sections = useMemo(() => {
88 return [
89 {
90 title: _(msg`All`),
91 component: (
92 <NotificationsTab
93 filter="all"
94 isActive={activeTab === 0}
95 isLoading={isLoadingAll}
96 hasNew={hasNew}
97 setIsLoadingLatest={setIsLoadingAll}
98 checkUnread={checkUnreadAll}
99 />
100 ),
101 },
102 {
103 title: _(msg`Mentions`),
104 component: (
105 <NotificationsTab
106 filter="mentions"
107 isActive={activeTab === 1}
108 isLoading={isLoadingMentions}
109 hasNew={false /* We don't know for sure */}
110 setIsLoadingLatest={setIsLoadingMentions}
111 checkUnread={checkUnreadMentions}
112 />
113 ),
114 },
115 ]
116 }, [
117 _,
118 hasNew,
119 checkUnreadAll,
120 checkUnreadMentions,
121 activeTab,
122 isLoadingAll,
123 isLoadingMentions,
124 ])
125
126 return (
127 <Layout.Screen testID="notificationsScreen">
128 <Layout.Header.Outer noBottomBorder sticky={false}>
129 <Layout.Header.MenuButton />
130 <Layout.Header.Content>
131 <Layout.Header.TitleText>
132 <Trans>Notifications</Trans>
133 </Layout.Header.TitleText>
134 </Layout.Header.Content>
135 <Layout.Header.Slot>
136 <Link
137 to={{screen: 'NotificationSettings'}}
138 label={_(msg`Notification settings`)}
139 size="small"
140 variant="ghost"
141 color="secondary"
142 shape={enableSquareButtons ? 'square' : 'round'}
143 style={[a.justify_center]}>
144 <ButtonIcon icon={isLoading ? Loader : SettingsIcon} size="lg" />
145 </Link>
146 </Layout.Header.Slot>
147 </Layout.Header.Outer>
148 <Pager
149 onPageSelected={onPageSelected}
150 renderTabBar={props => (
151 <Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}>
152 <TabBar
153 {...props}
154 items={sections.map(section => section.title)}
155 onPressSelected={() => emitSoftReset()}
156 />
157 </Layout.Center>
158 )}
159 initialPage={initialActiveTab}>
160 {sections.map((section, i) => (
161 <View key={i}>{section.component}</View>
162 ))}
163 </Pager>
164 <FAB
165 testID="composeFAB"
166 onPress={() => openComposer({logContext: 'Fab'})}
167 icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
168 accessibilityRole="button"
169 accessibilityLabel={_(msg`New post`)}
170 accessibilityHint=""
171 />
172 </Layout.Screen>
173 )
174}
175
176function NotificationsTab({
177 filter,
178 isActive,
179 isLoading,
180 hasNew,
181 checkUnread,
182 setIsLoadingLatest,
183}: {
184 filter: 'all' | 'mentions'
185 isActive: boolean
186 isLoading: boolean
187 hasNew: boolean
188 checkUnread: ({invalidate}: {invalidate: boolean}) => Promise<void>
189 setIsLoadingLatest: (v: boolean) => void
190}) {
191 const {_} = useLingui()
192 const [isScrolledDown, setIsScrolledDown] = useState(false)
193 const scrollElRef = useRef<ListMethods>(null)
194 const queryClient = useQueryClient()
195 const isScreenFocused = useIsFocused()
196 const isFocusedAndActive = isScreenFocused && isActive
197
198 // event handlers
199 // =
200 const scrollToTop = useCallback(() => {
201 scrollElRef.current?.scrollToOffset({animated: IS_NATIVE, offset: 0})
202 }, [scrollElRef])
203
204 const onPressLoadLatest = useCallback(() => {
205 scrollToTop()
206 if (hasNew) {
207 // render what we have now
208 truncateAndInvalidate(queryClient, NOTIFS_RQKEY(filter))
209 } else if (!isLoading) {
210 // check with the server
211 setIsLoadingLatest(true)
212 checkUnread({invalidate: true})
213 .catch(() => undefined)
214 .then(() => setIsLoadingLatest(false))
215 }
216 }, [
217 scrollToTop,
218 queryClient,
219 checkUnread,
220 hasNew,
221 isLoading,
222 setIsLoadingLatest,
223 filter,
224 ])
225
226 const onFocusCheckLatest = useNonReactiveCallback(() => {
227 // on focus, check for latest, but only invalidate if the user
228 // isnt scrolled down to avoid moving content underneath them
229 let currentIsScrolledDown
230 if (IS_NATIVE) {
231 currentIsScrolledDown = isScrolledDown
232 } else {
233 // On the web, this isn't always updated in time so
234 // we're just going to look it up synchronously.
235 currentIsScrolledDown = window.scrollY > 200
236 }
237 checkUnread({invalidate: !currentIsScrolledDown})
238 })
239
240 // on-visible setup
241 // =
242 useFocusEffect(
243 useCallback(() => {
244 if (isFocusedAndActive) {
245 logger.debug('NotificationsScreen: Focus')
246 onFocusCheckLatest()
247 }
248 }, [onFocusCheckLatest, isFocusedAndActive]),
249 )
250
251 useEffect(() => {
252 if (!isFocusedAndActive) {
253 return
254 }
255 return listenSoftReset(onPressLoadLatest)
256 }, [onPressLoadLatest, isFocusedAndActive])
257
258 return (
259 <>
260 <MainScrollProvider>
261 <NotificationFeed
262 enabled={isFocusedAndActive}
263 filter={filter}
264 refreshNotifications={() => checkUnread({invalidate: true})}
265 onScrolledDownChange={setIsScrolledDown}
266 scrollElRef={scrollElRef}
267 ListHeaderComponent={
268 filter === 'mentions' ? (
269 <DisabledNotificationsWarning active={isFocusedAndActive} />
270 ) : null
271 }
272 />
273 </MainScrollProvider>
274 {(isScrolledDown || hasNew) && (
275 <LoadLatestBtn
276 onPress={onPressLoadLatest}
277 label={_(msg`Load new notifications`)}
278 showIndicator={hasNew}
279 />
280 )}
281 </>
282 )
283}
284
285function DisabledNotificationsWarning({active}: {active: boolean}) {
286 const t = useTheme()
287 const {_} = useLingui()
288 const {data} = useNotificationSettingsQuery({enabled: active})
289
290 if (!data) return null
291
292 if (!data.reply.list && !data.quote.list && !data.mention.list) {
293 // mention tab notifications are disabled
294 return (
295 <View style={[a.py_md, a.px_lg, a.border_b, t.atoms.border_contrast_low]}>
296 <Admonition type="warning">
297 <Trans>
298 You have completely disabled reply, quote, and mention
299 notifications, so this tab will no longer update. To adjust this,
300 visit your{' '}
301 <InlineLinkText
302 label={_(msg`Visit your notification settings`)}
303 to={{screen: 'NotificationSettings'}}>
304 notification settings
305 </InlineLinkText>
306 .
307 </Trans>
308 </Admonition>
309 </View>
310 )
311 }
312
313 return null
314}