Bluesky app fork with some witchin' additions 馃挮
at main 318 lines 10 kB view raw
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}