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