Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at f8975036440051185486f6b2c00a201ef2e18a8c 317 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, 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}