Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at theme-changes 314 lines 9.9 kB view raw
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}