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, Trans} from '@lingui/macro'
4import {useLingui} from '@lingui/react'
5import {useFocusEffect, useIsFocused} from '@react-navigation/native'
6import {useQueryClient} from '@tanstack/react-query'
7
8import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
9import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
10import {ComposeIcon2} from '#/lib/icons'
11import {
12 type NativeStackScreenProps,
13 type NotificationsTabNavigatorParams,
14} from '#/lib/routes/types'
15import {s} from '#/lib/styles'
16import {logger} from '#/logger'
17import {emitSoftReset, listenSoftReset} from '#/state/events'
18import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
19import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
20import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
21import {
22 useUnreadNotifications,
23 useUnreadNotificationsApi,
24} from '#/state/queries/notifications/unread'
25import {truncateAndInvalidate} from '#/state/queries/util'
26import {useSetMinimalShellMode} from '#/state/shell'
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 setMinimalShellMode = useSetMinimalShellMode()
193 const [isScrolledDown, setIsScrolledDown] = useState(false)
194 const scrollElRef = useRef<ListMethods>(null)
195 const queryClient = useQueryClient()
196 const isScreenFocused = useIsFocused()
197 const isFocusedAndActive = isScreenFocused && isActive
198
199 // event handlers
200 // =
201 const scrollToTop = useCallback(() => {
202 scrollElRef.current?.scrollToOffset({animated: IS_NATIVE, offset: 0})
203 setMinimalShellMode(false)
204 }, [scrollElRef, setMinimalShellMode])
205
206 const onPressLoadLatest = useCallback(() => {
207 scrollToTop()
208 if (hasNew) {
209 // render what we have now
210 truncateAndInvalidate(queryClient, NOTIFS_RQKEY(filter))
211 } else if (!isLoading) {
212 // check with the server
213 setIsLoadingLatest(true)
214 checkUnread({invalidate: true})
215 .catch(() => undefined)
216 .then(() => setIsLoadingLatest(false))
217 }
218 }, [
219 scrollToTop,
220 queryClient,
221 checkUnread,
222 hasNew,
223 isLoading,
224 setIsLoadingLatest,
225 filter,
226 ])
227
228 const onFocusCheckLatest = useNonReactiveCallback(() => {
229 // on focus, check for latest, but only invalidate if the user
230 // isnt scrolled down to avoid moving content underneath them
231 let currentIsScrolledDown
232 if (IS_NATIVE) {
233 currentIsScrolledDown = isScrolledDown
234 } else {
235 // On the web, this isn't always updated in time so
236 // we're just going to look it up synchronously.
237 currentIsScrolledDown = window.scrollY > 200
238 }
239 checkUnread({invalidate: !currentIsScrolledDown})
240 })
241
242 // on-visible setup
243 // =
244 useFocusEffect(
245 useCallback(() => {
246 if (isFocusedAndActive) {
247 setMinimalShellMode(false)
248 logger.debug('NotificationsScreen: Focus')
249 onFocusCheckLatest()
250 }
251 }, [setMinimalShellMode, onFocusCheckLatest, isFocusedAndActive]),
252 )
253
254 useEffect(() => {
255 if (!isFocusedAndActive) {
256 return
257 }
258 return listenSoftReset(onPressLoadLatest)
259 }, [onPressLoadLatest, isFocusedAndActive])
260
261 return (
262 <>
263 <MainScrollProvider>
264 <NotificationFeed
265 enabled={isFocusedAndActive}
266 filter={filter}
267 refreshNotifications={() => checkUnread({invalidate: true})}
268 onScrolledDownChange={setIsScrolledDown}
269 scrollElRef={scrollElRef}
270 ListHeaderComponent={
271 filter === 'mentions' ? (
272 <DisabledNotificationsWarning active={isFocusedAndActive} />
273 ) : null
274 }
275 />
276 </MainScrollProvider>
277 {(isScrolledDown || hasNew) && (
278 <LoadLatestBtn
279 onPress={onPressLoadLatest}
280 label={_(msg`Load new notifications`)}
281 showIndicator={hasNew}
282 />
283 )}
284 </>
285 )
286}
287
288function DisabledNotificationsWarning({active}: {active: boolean}) {
289 const t = useTheme()
290 const {_} = useLingui()
291 const {data} = useNotificationSettingsQuery({enabled: active})
292
293 if (!data) return null
294
295 if (!data.reply.list && !data.quote.list && !data.mention.list) {
296 // mention tab notifications are disabled
297 return (
298 <View style={[a.py_md, a.px_lg, a.border_b, t.atoms.border_contrast_low]}>
299 <Admonition type="warning">
300 <Trans>
301 You have completely disabled reply, quote, and mention
302 notifications, so this tab will no longer update. To adjust this,
303 visit your{' '}
304 <InlineLinkText
305 label={_(msg`Visit your notification settings`)}
306 to={{screen: 'NotificationSettings'}}>
307 notification settings
308 </InlineLinkText>
309 .
310 </Trans>
311 </Admonition>
312 </View>
313 )
314 }
315
316 return null
317}