Refactor the notifications to cache and reuse results from the unread-notifs checks (#2017)

* Refactor the notifications to cache and reuse results from the unread-notifs checks

* Fix types
zio/stable
Paul Frazee 2023-11-29 10:20:14 -08:00 committed by GitHub
parent 620e002841
commit 9239efac9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 369 additions and 246 deletions

View File

@ -1,11 +1,22 @@
import { /**
AppBskyFeedDefs, * NOTE
AppBskyFeedPost, * The ./unread.ts API:
AppBskyFeedRepost, *
AppBskyFeedLike, * - Provides a `checkUnread()` function to sync with the server,
AppBskyNotificationListNotifications, * - Periodically calls `checkUnread()`, and
} from '@atproto/api' * - Caches the first page of notifications.
import chunk from 'lodash.chunk' *
* IMPORTANT: This query uses ./unread.ts's cache as its first page,
* IMPORTANT: which means the cache-freshness of this query is driven by the unread API.
*
* Follow these rules:
*
* 1. Call `checkUnread()` if you want to fetch latest in the background.
* 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately.
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
*/
import {AppBskyFeedDefs} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
InfiniteData, InfiniteData,
@ -13,50 +24,27 @@ import {
useQueryClient, useQueryClient,
QueryClient, QueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {getAgent} from '../../session'
import {useModerationOpts} from '../preferences' import {useModerationOpts} from '../preferences'
import {shouldFilterNotif} from './util' import {useUnreadNotificationsApi} from './unread'
import {fetchPage} from './util'
import {FeedPage} from './types'
import {useMutedThreads} from '#/state/muted-threads' import {useMutedThreads} from '#/state/muted-threads'
import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
const GROUPABLE_REASONS = ['like', 'repost', 'follow'] export type {NotificationType, FeedNotification, FeedPage} from './types'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48
type RQPageParam = string | undefined type RQPageParam = string | undefined
type NotificationType =
| 'post-like'
| 'feedgen-like'
| 'repost'
| 'mention'
| 'reply'
| 'quote'
| 'follow'
| 'unknown'
export function RQKEY() { export function RQKEY() {
return ['notification-feed'] return ['notification-feed']
} }
export interface FeedNotification {
_reactKey: string
type: NotificationType
notification: AppBskyNotificationListNotifications.Notification
additional?: AppBskyNotificationListNotifications.Notification[]
subjectUri?: string
subject?: AppBskyFeedDefs.PostView
}
export interface FeedPage {
cursor: string | undefined
items: FeedNotification[]
}
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads() const threadMutes = useMutedThreads()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
return useInfiniteQuery< return useInfiniteQuery<
@ -68,40 +56,21 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
>({ >({
queryKey: RQKEY(), queryKey: RQKEY(),
async queryFn({pageParam}: {pageParam: RQPageParam}) { async queryFn({pageParam}: {pageParam: RQPageParam}) {
const res = await getAgent().listNotifications({ // for the first page, we check the cached page held by the unread-checker first
if (!pageParam) {
const cachedPage = unreads.getCachedUnreadPage()
if (cachedPage) {
return cachedPage
}
}
// do a normal fetch
return fetchPage({
limit: PAGE_SIZE, limit: PAGE_SIZE,
cursor: pageParam, cursor: pageParam,
queryClient,
moderationOpts,
threadMutes,
}) })
// filter out notifs by mod rules
const notifs = res.data.notifications.filter(
notif => !shouldFilterNotif(notif, moderationOpts),
)
// group notifications which are essentially similar (follows, likes on a post)
let notifsGrouped = groupNotifications(notifs)
// we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts
const subjects = await fetchSubjects(notifsGrouped)
for (const notif of notifsGrouped) {
if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri)
if (notif.subject) {
precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
}
}
}
// apply thread muting
notifsGrouped = notifsGrouped.filter(
notif => !isThreadMuted(notif, threadMutes),
)
return {
cursor: res.data.cursor,
items: notifsGrouped,
}
}, },
initialPageParam: undefined, initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor, getNextPageParam: lastPage => lastPage.cursor,
@ -134,115 +103,3 @@ export function findPostInQueryData(
} }
return undefined return undefined
} }
function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) {
const ts = +new Date(notif.indexedAt)
let grouped = false
if (GROUPABLE_REASONS.includes(notif.reason)) {
for (const groupedNotif of groupedNotifs) {
const ts2 = +new Date(groupedNotif.notification.indexedAt)
if (
Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
notif.author.did !== groupedNotif.notification.author.did
) {
groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif)
grouped = true
break
}
}
}
if (!grouped) {
const type = toKnownType(notif)
groupedNotifs.push({
_reactKey: `notif-${notif.uri}`,
type,
notification: notif,
subjectUri: getSubjectUri(type, notif),
})
}
}
return groupedNotifs
}
async function fetchSubjects(
groupedNotifs: FeedNotification[],
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri) {
uris.add(notif.subjectUri)
}
}
const uriChunks = chunk(Array.from(uris), 25)
const postsChunks = await Promise.all(
uriChunks.map(uris =>
getAgent()
.app.bsky.feed.getPosts({uris})
.then(res => res.data.posts),
),
)
const map = new Map<string, AppBskyFeedDefs.PostView>()
for (const post of postsChunks.flat()) {
if (
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
) {
map.set(post.uri, post)
}
}
return map
}
function toKnownType(
notif: AppBskyNotificationListNotifications.Notification,
): NotificationType {
if (notif.reason === 'like') {
if (notif.reasonSubject?.includes('feed.generator')) {
return 'feedgen-like'
}
return 'post-like'
}
if (
notif.reason === 'repost' ||
notif.reason === 'mention' ||
notif.reason === 'reply' ||
notif.reason === 'quote' ||
notif.reason === 'follow'
) {
return notif.reason as NotificationType
}
return 'unknown'
}
function getSubjectUri(
type: NotificationType,
notif: AppBskyNotificationListNotifications.Notification,
): string | undefined {
if (type === 'reply' || type === 'quote' || type === 'mention') {
return notif.uri
} else if (type === 'post-like' || type === 'repost') {
if (
AppBskyFeedRepost.isRecord(notif.record) ||
AppBskyFeedLike.isRecord(notif.record)
) {
return typeof notif.record.subject?.uri === 'string'
? notif.record.subject?.uri
: undefined
}
}
}
function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
if (!notif.subject) {
return false
}
const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
return mutes.includes(record.reply?.root.uri || notif.subject.uri)
}

View File

@ -0,0 +1,34 @@
import {
AppBskyNotificationListNotifications,
AppBskyFeedDefs,
} from '@atproto/api'
export type NotificationType =
| 'post-like'
| 'feedgen-like'
| 'repost'
| 'mention'
| 'reply'
| 'quote'
| 'follow'
| 'unknown'
export interface FeedNotification {
_reactKey: string
type: NotificationType
notification: AppBskyNotificationListNotifications.Notification
additional?: AppBskyNotificationListNotifications.Notification[]
subjectUri?: string
subject?: AppBskyFeedDefs.PostView
}
export interface FeedPage {
cursor: string | undefined
items: FeedNotification[]
}
export interface CachedFeedPage {
sessDid: string // used to invalidate on session changes
syncedAt: Date
data: FeedPage | undefined
}

View File

@ -1,10 +1,19 @@
/**
* A kind of companion API to ./feed.ts. See that file for more info.
*/
import React from 'react' import React from 'react'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {useQueryClient} from '@tanstack/react-query'
import BroadcastChannel from '#/lib/broadcast' import BroadcastChannel from '#/lib/broadcast'
import {useSession, getAgent} from '#/state/session' import {useSession, getAgent} from '#/state/session'
import {useModerationOpts} from '../preferences' import {useModerationOpts} from '../preferences'
import {shouldFilterNotif} from './util' import {fetchPage} from './util'
import {CachedFeedPage, FeedPage} from './types'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useMutedThreads} from '#/state/muted-threads'
import {RQKEY as RQKEY_NOTIFS} from './feed'
import {logger} from '#/logger'
const UPDATE_INTERVAL = 30 * 1e3 // 30sec const UPDATE_INTERVAL = 30 * 1e3 // 30sec
@ -14,7 +23,8 @@ type StateContext = string
interface ApiContext { interface ApiContext {
markAllRead: () => Promise<void> markAllRead: () => Promise<void>
checkUnread: () => Promise<void> checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
getCachedUnreadPage: () => FeedPage | undefined
} }
const stateContext = React.createContext<StateContext>('') const stateContext = React.createContext<StateContext>('')
@ -22,16 +32,23 @@ const stateContext = React.createContext<StateContext>('')
const apiContext = React.createContext<ApiContext>({ const apiContext = React.createContext<ApiContext>({
async markAllRead() {}, async markAllRead() {},
async checkUnread() {}, async checkUnread() {},
getCachedUnreadPage: () => undefined,
}) })
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const {hasSession} = useSession() const {hasSession, currentAccount} = useSession()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const threadMutes = useMutedThreads()
const [numUnread, setNumUnread] = React.useState('') const [numUnread, setNumUnread] = React.useState('')
const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null) const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
const lastSyncRef = React.useRef<Date>(new Date()) const cacheRef = React.useRef<CachedFeedPage>({
sessDid: currentAccount?.did || '',
syncedAt: new Date(),
data: undefined,
})
// periodic sync // periodic sync
React.useEffect(() => { React.useEffect(() => {
@ -46,14 +63,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// listen for broadcasts // listen for broadcasts
React.useEffect(() => { React.useEffect(() => {
const listener = ({data}: MessageEvent) => { const listener = ({data}: MessageEvent) => {
lastSyncRef.current = new Date() cacheRef.current = {
sessDid: currentAccount?.did || '',
syncedAt: new Date(),
data: undefined,
}
setNumUnread(data.event) setNumUnread(data.event)
} }
broadcast.addEventListener('message', listener) broadcast.addEventListener('message', listener)
return () => { return () => {
broadcast.removeEventListener('message', listener) broadcast.removeEventListener('message', listener)
} }
}, [setNumUnread]) }, [setNumUnread, currentAccount])
// create API // create API
const api = React.useMemo<ApiContext>(() => { const api = React.useMemo<ApiContext>(() => {
@ -61,7 +82,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
async markAllRead() { async markAllRead() {
// update server // update server
await getAgent().updateSeenNotifications( await getAgent().updateSeenNotifications(
lastSyncRef.current.toISOString(), cacheRef.current.syncedAt.toISOString(),
) )
// update & broadcast // update & broadcast
@ -69,36 +90,59 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
broadcast.postMessage({event: ''}) broadcast.postMessage({event: ''})
}, },
async checkUnread() { async checkUnread({invalidate}: {invalidate?: boolean} = {}) {
try {
if (!getAgent().session) return if (!getAgent().session) return
// count // count
const res = await getAgent().listNotifications({limit: 40}) const page = await fetchPage({
const filtered = res.data.notifications.filter( cursor: undefined,
notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts), limit: 40,
) queryClient,
const num = moderationOpts,
filtered.length >= 30 threadMutes,
})
const unreadCount = countUnread(page)
const unreadCountStr =
unreadCount >= 30
? '30+' ? '30+'
: filtered.length === 0 : unreadCount === 0
? '' ? ''
: String(filtered.length) : String(unreadCount)
if (isNative) { if (isNative) {
Notifications.setBadgeCountAsync(Math.min(filtered.length, 30)) Notifications.setBadgeCountAsync(Math.min(unreadCount, 30))
} }
// track last sync // track last sync
const now = new Date() const now = new Date()
const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt) const lastIndexed =
lastSyncRef.current = page.items[0] && new Date(page.items[0].notification.indexedAt)
!lastIndexed || now > lastIndexed ? now : lastIndexed cacheRef.current = {
sessDid: currentAccount?.did || '',
data: page,
syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
}
// update & broadcast // update & broadcast
setNumUnread(num) setNumUnread(unreadCountStr)
broadcast.postMessage({event: num}) if (invalidate) {
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
}
broadcast.postMessage({event: unreadCountStr})
} catch (e) {
logger.error('Failed to check unread notifications', {error: e})
}
},
getCachedUnreadPage() {
// return cached page if was for the current user
// (protects against session changes serving data from the past session)
if (cacheRef.current.sessDid === currentAccount?.did) {
return cacheRef.current.data
}
}, },
} }
}, [setNumUnread, moderationOpts]) }, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount])
checkUnreadRef.current = api.checkUnread checkUnreadRef.current = api.checkUnread
return ( return (
@ -115,3 +159,20 @@ export function useUnreadNotifications() {
export function useUnreadNotificationsApi() { export function useUnreadNotificationsApi() {
return React.useContext(apiContext) return React.useContext(apiContext)
} }
function countUnread(page: FeedPage) {
let num = 0
for (const item of page.items) {
if (!item.notification.isRead) {
num++
}
if (item.additional) {
for (const item2 of item.additional) {
if (!item2.isRead) {
num++
}
}
}
}
return num
}

View File

@ -3,10 +3,78 @@ import {
ModerationOpts, ModerationOpts,
moderateProfile, moderateProfile,
moderatePost, moderatePost,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
} from '@atproto/api' } from '@atproto/api'
import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session'
import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
import {NotificationType, FeedNotification, FeedPage} from './types'
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48
// exported api
// =
export async function fetchPage({
cursor,
limit,
queryClient,
moderationOpts,
threadMutes,
}: {
cursor: string | undefined
limit: number
queryClient: QueryClient
moderationOpts: ModerationOpts | undefined
threadMutes: string[]
}): Promise<FeedPage> {
const res = await getAgent().listNotifications({
limit,
cursor,
})
// filter out notifs by mod rules
const notifs = res.data.notifications.filter(
notif => !shouldFilterNotif(notif, moderationOpts),
)
// group notifications which are essentially similar (follows, likes on a post)
let notifsGrouped = groupNotifications(notifs)
// we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts
const subjects = await fetchSubjects(notifsGrouped)
for (const notif of notifsGrouped) {
if (notif.subjectUri) {
notif.subject = subjects.get(notif.subjectUri)
if (notif.subject) {
precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
}
}
}
// apply thread muting
notifsGrouped = notifsGrouped.filter(
notif => !isThreadMuted(notif, threadMutes),
)
return {
cursor: res.data.cursor,
items: notifsGrouped,
}
}
// internal methods
// =
// TODO this should be in the sdk as moderateNotification -prf // TODO this should be in the sdk as moderateNotification -prf
export function shouldFilterNotif( function shouldFilterNotif(
notif: AppBskyNotificationListNotifications.Notification, notif: AppBskyNotificationListNotifications.Notification,
moderationOpts: ModerationOpts | undefined, moderationOpts: ModerationOpts | undefined,
): boolean { ): boolean {
@ -36,3 +104,115 @@ export function shouldFilterNotif(
// (this requires fetching the post) // (this requires fetching the post)
return false return false
} }
function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) {
const ts = +new Date(notif.indexedAt)
let grouped = false
if (GROUPABLE_REASONS.includes(notif.reason)) {
for (const groupedNotif of groupedNotifs) {
const ts2 = +new Date(groupedNotif.notification.indexedAt)
if (
Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
notif.author.did !== groupedNotif.notification.author.did
) {
groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif)
grouped = true
break
}
}
}
if (!grouped) {
const type = toKnownType(notif)
groupedNotifs.push({
_reactKey: `notif-${notif.uri}`,
type,
notification: notif,
subjectUri: getSubjectUri(type, notif),
})
}
}
return groupedNotifs
}
async function fetchSubjects(
groupedNotifs: FeedNotification[],
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri) {
uris.add(notif.subjectUri)
}
}
const uriChunks = chunk(Array.from(uris), 25)
const postsChunks = await Promise.all(
uriChunks.map(uris =>
getAgent()
.app.bsky.feed.getPosts({uris})
.then(res => res.data.posts),
),
)
const map = new Map<string, AppBskyFeedDefs.PostView>()
for (const post of postsChunks.flat()) {
if (
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
) {
map.set(post.uri, post)
}
}
return map
}
function toKnownType(
notif: AppBskyNotificationListNotifications.Notification,
): NotificationType {
if (notif.reason === 'like') {
if (notif.reasonSubject?.includes('feed.generator')) {
return 'feedgen-like'
}
return 'post-like'
}
if (
notif.reason === 'repost' ||
notif.reason === 'mention' ||
notif.reason === 'reply' ||
notif.reason === 'quote' ||
notif.reason === 'follow'
) {
return notif.reason as NotificationType
}
return 'unknown'
}
function getSubjectUri(
type: NotificationType,
notif: AppBskyNotificationListNotifications.Notification,
): string | undefined {
if (type === 'reply' || type === 'quote' || type === 'mention') {
return notif.uri
} else if (type === 'post-like' || type === 'repost') {
if (
AppBskyFeedRepost.isRecord(notif.record) ||
AppBskyFeedLike.isRecord(notif.record)
) {
return typeof notif.record.subject?.uri === 'string'
? notif.record.subject?.uri
: undefined
}
}
}
function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean {
if (!notif.subject) {
return false
}
const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects()
return mutes.includes(record.reply?.root.uri || notif.subject.uri)
}

View File

@ -35,15 +35,13 @@ export function Feed({
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {markAllRead} = useUnreadNotificationsApi() const {markAllRead, checkUnread} = useUnreadNotificationsApi()
const { const {
data, data,
isLoading,
isFetching, isFetching,
isFetched, isFetched,
isError, isError,
error, error,
refetch,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
fetchNextPage, fetchNextPage,
@ -52,13 +50,11 @@ export function Feed({
const firstItem = data?.pages[0]?.items[0] const firstItem = data?.pages[0]?.items[0]
// mark all read on fresh data // mark all read on fresh data
// (this will fire each time firstItem changes)
React.useEffect(() => { React.useEffect(() => {
let cleanup
if (firstItem) { if (firstItem) {
const to = setTimeout(() => markAllRead(), 250) markAllRead()
cleanup = () => clearTimeout(to)
} }
return cleanup
}, [firstItem, markAllRead]) }, [firstItem, markAllRead])
const items = React.useMemo(() => { const items = React.useMemo(() => {
@ -83,7 +79,7 @@ export function Feed({
const onRefresh = React.useCallback(async () => { const onRefresh = React.useCallback(async () => {
try { try {
setIsPTRing(true) setIsPTRing(true)
await refetch() await checkUnread({invalidate: true})
} catch (err) { } catch (err) {
logger.error('Failed to refresh notifications feed', { logger.error('Failed to refresh notifications feed', {
error: err, error: err,
@ -91,7 +87,7 @@ export function Feed({
} finally { } finally {
setIsPTRing(false) setIsPTRing(false)
} }
}, [refetch, setIsPTRing]) }, [checkUnread, setIsPTRing])
const onEndReached = React.useCallback(async () => { const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return if (isFetching || !hasNextPage || isError) return
@ -136,21 +132,6 @@ export function Feed({
[onPressRetryLoadMore, moderationOpts], [onPressRetryLoadMore, moderationOpts],
) )
const showHeaderSpinner = !isPTRing && isFetching && !isLoading
const FeedHeader = React.useCallback(
() => (
<View>
{ListHeaderComponent ? <ListHeaderComponent /> : null}
{showHeaderSpinner ? (
<View style={{padding: 10}}>
<ActivityIndicator />
</View>
) : null}
</View>
),
[ListHeaderComponent, showHeaderSpinner],
)
const FeedFooter = React.useCallback( const FeedFooter = React.useCallback(
() => () =>
isFetchingNextPage ? ( isFetchingNextPage ? (
@ -180,7 +161,7 @@ export function Feed({
data={items} data={items}
keyExtractor={item => item._reactKey} keyExtractor={item => item._reactKey}
renderItem={renderItem} renderItem={renderItem}
ListHeaderComponent={FeedHeader} ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={FeedFooter} ListFooterComponent={FeedFooter}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl

View File

@ -19,7 +19,10 @@ import {logger} from '#/logger'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {
useUnreadNotifications,
useUnreadNotificationsApi,
} from '#/state/queries/notifications/unread'
import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed'
import {listenSoftReset, emitSoftReset} from '#/state/events' import {listenSoftReset, emitSoftReset} from '#/state/events'
@ -35,8 +38,9 @@ export function NotificationsScreen({}: Props) {
const {screen} = useAnalytics() const {screen} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
const unreadNotifs = useUnreadNotifications()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const unreadNotifs = useUnreadNotifications()
const unreadApi = useUnreadNotificationsApi()
const hasNew = !!unreadNotifs const hasNew = !!unreadNotifs
// event handlers // event handlers
@ -48,10 +52,16 @@ export function NotificationsScreen({}: Props) {
const onPressLoadLatest = React.useCallback(() => { const onPressLoadLatest = React.useCallback(() => {
scrollToTop() scrollToTop()
if (hasNew) {
// render what we have now
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: NOTIFS_RQKEY(), queryKey: NOTIFS_RQKEY(),
}) })
}, [scrollToTop, queryClient]) } else {
// check with the server
unreadApi.checkUnread({invalidate: true})
}
}, [scrollToTop, queryClient, unreadApi, hasNew])
// on-visible setup // on-visible setup
// = // =