Files
bsky-social-app/src/view/screens/Notifications.tsx
T
2026-05-15 12:46:53 -07:00

312 lines
9.5 KiB
TypeScript

import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {
type NativeStackScreenProps,
type NotificationsTabNavigatorParams,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {emitSoftReset, listenSoftReset} from '#/state/events'
import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {
useUnreadNotifications,
useUnreadNotificationsApi,
} from '#/state/queries/notifications/unread'
import {truncateAndInvalidate} from '#/state/queries/util'
import {NotificationFeed} from '#/view/com/notifications/NotificationFeed'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {FAB} from '#/view/com/util/fab/FAB'
import {type ListMethods} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {ButtonIcon} from '#/components/Button'
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
import * as Layout from '#/components/Layout'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {IS_NATIVE} from '#/env'
// We don't currently persist this across reloads since
// you gotta visit All to clear the badge anyway.
// But let's at least persist it during the sesssion.
let lastActiveTab = 0
type Props = NativeStackScreenProps<
NotificationsTabNavigatorParams,
'Notifications'
>
export function NotificationsScreen({}: Props) {
const {_} = useLingui()
const t = useTheme()
const {openComposer} = useOpenComposer()
const unreadNotifs = useUnreadNotifications()
const hasNew = !!unreadNotifs
const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi()
const [isLoadingAll, setIsLoadingAll] = useState(false)
const [isLoadingMentions, setIsLoadingMentions] = useState(false)
const initialActiveTab = lastActiveTab
const [activeTab, setActiveTab] = useState(initialActiveTab)
const isLoading = activeTab === 0 ? isLoadingAll : isLoadingMentions
const onPageSelected = useCallback(
(index: number) => {
setActiveTab(index)
lastActiveTab = index
},
[setActiveTab],
)
const queryClient = useQueryClient()
const checkUnreadMentions = useCallback(
async ({invalidate}: {invalidate: boolean}) => {
if (invalidate) {
return truncateAndInvalidate(queryClient, NOTIFS_RQKEY('mentions'))
} else {
// Background polling is not implemented for the mentions tab.
// Just ignore it.
}
},
[queryClient],
)
const sections = useMemo(() => {
return [
{
title: _(msg`All`),
component: (
<NotificationsTab
filter="all"
isActive={activeTab === 0}
isLoading={isLoadingAll}
hasNew={hasNew}
setIsLoadingLatest={setIsLoadingAll}
checkUnread={checkUnreadAll}
/>
),
},
{
title: _(msg`Mentions`),
component: (
<NotificationsTab
filter="mentions"
isActive={activeTab === 1}
isLoading={isLoadingMentions}
hasNew={false /* We don't know for sure */}
setIsLoadingLatest={setIsLoadingMentions}
checkUnread={checkUnreadMentions}
/>
),
},
]
}, [
_,
hasNew,
checkUnreadAll,
checkUnreadMentions,
activeTab,
isLoadingAll,
isLoadingMentions,
])
return (
<Layout.Screen testID="notificationsScreen">
<Layout.Header.Outer noBottomBorder sticky={false}>
<Layout.Header.MenuButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Notifications</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot>
<Link
to={{screen: 'NotificationSettings'}}
label={_(msg`Notification settings`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={isLoading ? Loader : SettingsIcon} size="lg" />
</Link>
</Layout.Header.Slot>
</Layout.Header.Outer>
<Pager
onPageSelected={onPageSelected}
renderTabBar={props => (
<Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}>
<TabBar
{...props}
items={sections.map(section => section.title)}
onPressSelected={() => emitSoftReset()}
/>
</Layout.Center>
)}
initialPage={initialActiveTab}>
{sections.map((section, i) => (
<View key={i}>{section.component}</View>
))}
</Pager>
<FAB
testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})}
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityHint=""
/>
</Layout.Screen>
)
}
function NotificationsTab({
filter,
isActive,
isLoading,
hasNew,
checkUnread,
setIsLoadingLatest,
}: {
filter: 'all' | 'mentions'
isActive: boolean
isLoading: boolean
hasNew: boolean
checkUnread: ({invalidate}: {invalidate: boolean}) => Promise<void>
setIsLoadingLatest: (v: boolean) => void
}) {
const {_} = useLingui()
const [isScrolledDown, setIsScrolledDown] = useState(false)
const scrollElRef = useRef<ListMethods>(null)
const queryClient = useQueryClient()
const isScreenFocused = useIsFocused()
const isFocusedAndActive = isScreenFocused && isActive
// event handlers
// =
const scrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({animated: IS_NATIVE, offset: 0})
}, [scrollElRef])
const onPressLoadLatest = useCallback(() => {
scrollToTop()
if (hasNew) {
// render what we have now
truncateAndInvalidate(queryClient, NOTIFS_RQKEY(filter))
} else if (!isLoading) {
// check with the server
setIsLoadingLatest(true)
checkUnread({invalidate: true})
.catch(() => undefined)
.then(() => setIsLoadingLatest(false))
}
}, [
scrollToTop,
queryClient,
checkUnread,
hasNew,
isLoading,
setIsLoadingLatest,
filter,
])
const onFocusCheckLatest = useNonReactiveCallback(() => {
// on focus, check for latest, but only invalidate if the user
// isnt scrolled down to avoid moving content underneath them
let currentIsScrolledDown
if (IS_NATIVE) {
currentIsScrolledDown = isScrolledDown
} else {
// On the web, this isn't always updated in time so
// we're just going to look it up synchronously.
currentIsScrolledDown = window.scrollY > 200
}
checkUnread({invalidate: !currentIsScrolledDown})
})
// on-visible setup
// =
useFocusEffect(
useCallback(() => {
if (isFocusedAndActive) {
logger.debug('NotificationsScreen: Focus')
onFocusCheckLatest()
}
}, [onFocusCheckLatest, isFocusedAndActive]),
)
useEffect(() => {
if (!isFocusedAndActive) {
return
}
return listenSoftReset(onPressLoadLatest)
}, [onPressLoadLatest, isFocusedAndActive])
return (
<>
<MainScrollProvider>
<NotificationFeed
enabled={isFocusedAndActive}
filter={filter}
refreshNotifications={() => checkUnread({invalidate: true})}
onScrolledDownChange={setIsScrolledDown}
scrollElRef={scrollElRef}
ListHeaderComponent={
filter === 'mentions' ? (
<DisabledNotificationsWarning active={isFocusedAndActive} />
) : null
}
/>
</MainScrollProvider>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onPressLoadLatest}
label={_(msg`Load new notifications`)}
showIndicator={hasNew}
/>
)}
</>
)
}
function DisabledNotificationsWarning({active}: {active: boolean}) {
const t = useTheme()
const {_} = useLingui()
const {data} = useNotificationSettingsQuery({enabled: active})
if (!data) return null
if (!data.reply.list && !data.quote.list && !data.mention.list) {
// mention tab notifications are disabled
return (
<View style={[a.py_md, a.px_lg, a.border_b, t.atoms.border_contrast_low]}>
<Admonition type="warning">
<Trans>
You have completely disabled reply, quote, and mention
notifications, so this tab will no longer update. To adjust this,
visit your{' '}
<InlineLinkText
label={_(msg`Visit your notification settings`)}
to={{screen: 'NotificationSettings'}}>
notification settings
</InlineLinkText>
.
</Trans>
</Admonition>
</View>
)
}
return null
}