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 typeszio/stable
parent
620e002841
commit
9239efac9c
|
@ -1,11 +1,22 @@
|
|||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedRepost,
|
||||
AppBskyFeedLike,
|
||||
AppBskyNotificationListNotifications,
|
||||
} from '@atproto/api'
|
||||
import chunk from 'lodash.chunk'
|
||||
/**
|
||||
* NOTE
|
||||
* The ./unread.ts API:
|
||||
*
|
||||
* - Provides a `checkUnread()` function to sync with the server,
|
||||
* - Periodically calls `checkUnread()`, and
|
||||
* - Caches the first page of notifications.
|
||||
*
|
||||
* 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 {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
|
@ -13,50 +24,27 @@ import {
|
|||
useQueryClient,
|
||||
QueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import {getAgent} from '../../session'
|
||||
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 {precacheProfile as precacheResolvedUri} from '../resolve-uri'
|
||||
|
||||
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
|
||||
export type {NotificationType, FeedNotification, FeedPage} from './types'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
const MS_1HR = 1e3 * 60 * 60
|
||||
const MS_2DAY = MS_1HR * 48
|
||||
|
||||
type RQPageParam = string | undefined
|
||||
type NotificationType =
|
||||
| 'post-like'
|
||||
| 'feedgen-like'
|
||||
| 'repost'
|
||||
| 'mention'
|
||||
| 'reply'
|
||||
| 'quote'
|
||||
| 'follow'
|
||||
| 'unknown'
|
||||
|
||||
export function RQKEY() {
|
||||
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}) {
|
||||
const queryClient = useQueryClient()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const threadMutes = useMutedThreads()
|
||||
const unreads = useUnreadNotificationsApi()
|
||||
const enabled = opts?.enabled !== false
|
||||
|
||||
return useInfiniteQuery<
|
||||
|
@ -68,40 +56,21 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
|||
>({
|
||||
queryKey: RQKEY(),
|
||||
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
||||
const res = await getAgent().listNotifications({
|
||||
limit: PAGE_SIZE,
|
||||
cursor: pageParam,
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// apply thread muting
|
||||
notifsGrouped = notifsGrouped.filter(
|
||||
notif => !isThreadMuted(notif, threadMutes),
|
||||
)
|
||||
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
items: notifsGrouped,
|
||||
}
|
||||
// do a normal fetch
|
||||
return fetchPage({
|
||||
limit: PAGE_SIZE,
|
||||
cursor: pageParam,
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
threadMutes,
|
||||
})
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: lastPage => lastPage.cursor,
|
||||
|
@ -134,115 +103,3 @@ export function findPostInQueryData(
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -1,10 +1,19 @@
|
|||
/**
|
||||
* A kind of companion API to ./feed.ts. See that file for more info.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {useSession, getAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '../preferences'
|
||||
import {shouldFilterNotif} from './util'
|
||||
import {fetchPage} from './util'
|
||||
import {CachedFeedPage, FeedPage} from './types'
|
||||
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
|
||||
|
||||
|
@ -14,7 +23,8 @@ type StateContext = string
|
|||
|
||||
interface ApiContext {
|
||||
markAllRead: () => Promise<void>
|
||||
checkUnread: () => Promise<void>
|
||||
checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
|
||||
getCachedUnreadPage: () => FeedPage | undefined
|
||||
}
|
||||
|
||||
const stateContext = React.createContext<StateContext>('')
|
||||
|
@ -22,16 +32,23 @@ const stateContext = React.createContext<StateContext>('')
|
|||
const apiContext = React.createContext<ApiContext>({
|
||||
async markAllRead() {},
|
||||
async checkUnread() {},
|
||||
getCachedUnreadPage: () => undefined,
|
||||
})
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const {hasSession} = useSession()
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const threadMutes = useMutedThreads()
|
||||
|
||||
const [numUnread, setNumUnread] = React.useState('')
|
||||
|
||||
const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null)
|
||||
const lastSyncRef = React.useRef<Date>(new Date())
|
||||
const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null)
|
||||
const cacheRef = React.useRef<CachedFeedPage>({
|
||||
sessDid: currentAccount?.did || '',
|
||||
syncedAt: new Date(),
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
// periodic sync
|
||||
React.useEffect(() => {
|
||||
|
@ -46,14 +63,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
// listen for broadcasts
|
||||
React.useEffect(() => {
|
||||
const listener = ({data}: MessageEvent) => {
|
||||
lastSyncRef.current = new Date()
|
||||
cacheRef.current = {
|
||||
sessDid: currentAccount?.did || '',
|
||||
syncedAt: new Date(),
|
||||
data: undefined,
|
||||
}
|
||||
setNumUnread(data.event)
|
||||
}
|
||||
broadcast.addEventListener('message', listener)
|
||||
return () => {
|
||||
broadcast.removeEventListener('message', listener)
|
||||
}
|
||||
}, [setNumUnread])
|
||||
}, [setNumUnread, currentAccount])
|
||||
|
||||
// create API
|
||||
const api = React.useMemo<ApiContext>(() => {
|
||||
|
@ -61,7 +82,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
async markAllRead() {
|
||||
// update server
|
||||
await getAgent().updateSeenNotifications(
|
||||
lastSyncRef.current.toISOString(),
|
||||
cacheRef.current.syncedAt.toISOString(),
|
||||
)
|
||||
|
||||
// update & broadcast
|
||||
|
@ -69,36 +90,59 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
broadcast.postMessage({event: ''})
|
||||
},
|
||||
|
||||
async checkUnread() {
|
||||
if (!getAgent().session) return
|
||||
async checkUnread({invalidate}: {invalidate?: boolean} = {}) {
|
||||
try {
|
||||
if (!getAgent().session) return
|
||||
|
||||
// count
|
||||
const res = await getAgent().listNotifications({limit: 40})
|
||||
const filtered = res.data.notifications.filter(
|
||||
notif => !notif.isRead && !shouldFilterNotif(notif, moderationOpts),
|
||||
)
|
||||
const num =
|
||||
filtered.length >= 30
|
||||
? '30+'
|
||||
: filtered.length === 0
|
||||
? ''
|
||||
: String(filtered.length)
|
||||
if (isNative) {
|
||||
Notifications.setBadgeCountAsync(Math.min(filtered.length, 30))
|
||||
// count
|
||||
const page = await fetchPage({
|
||||
cursor: undefined,
|
||||
limit: 40,
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
threadMutes,
|
||||
})
|
||||
const unreadCount = countUnread(page)
|
||||
const unreadCountStr =
|
||||
unreadCount >= 30
|
||||
? '30+'
|
||||
: unreadCount === 0
|
||||
? ''
|
||||
: String(unreadCount)
|
||||
if (isNative) {
|
||||
Notifications.setBadgeCountAsync(Math.min(unreadCount, 30))
|
||||
}
|
||||
|
||||
// track last sync
|
||||
const now = new Date()
|
||||
const lastIndexed =
|
||||
page.items[0] && new Date(page.items[0].notification.indexedAt)
|
||||
cacheRef.current = {
|
||||
sessDid: currentAccount?.did || '',
|
||||
data: page,
|
||||
syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
|
||||
}
|
||||
|
||||
// update & broadcast
|
||||
setNumUnread(unreadCountStr)
|
||||
if (invalidate) {
|
||||
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
|
||||
}
|
||||
broadcast.postMessage({event: unreadCountStr})
|
||||
} catch (e) {
|
||||
logger.error('Failed to check unread notifications', {error: e})
|
||||
}
|
||||
},
|
||||
|
||||
// track last sync
|
||||
const now = new Date()
|
||||
const lastIndexed = filtered[0] && new Date(filtered[0].indexedAt)
|
||||
lastSyncRef.current =
|
||||
!lastIndexed || now > lastIndexed ? now : lastIndexed
|
||||
|
||||
// update & broadcast
|
||||
setNumUnread(num)
|
||||
broadcast.postMessage({event: num})
|
||||
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
|
||||
|
||||
return (
|
||||
|
@ -115,3 +159,20 @@ export function useUnreadNotifications() {
|
|||
export function useUnreadNotificationsApi() {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -3,10 +3,78 @@ import {
|
|||
ModerationOpts,
|
||||
moderateProfile,
|
||||
moderatePost,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedRepost,
|
||||
AppBskyFeedLike,
|
||||
} 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
|
||||
export function shouldFilterNotif(
|
||||
function shouldFilterNotif(
|
||||
notif: AppBskyNotificationListNotifications.Notification,
|
||||
moderationOpts: ModerationOpts | undefined,
|
||||
): boolean {
|
||||
|
@ -36,3 +104,115 @@ export function shouldFilterNotif(
|
|||
// (this requires fetching the post)
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -35,15 +35,13 @@ export function Feed({
|
|||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {markAllRead} = useUnreadNotificationsApi()
|
||||
const {markAllRead, checkUnread} = useUnreadNotificationsApi()
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
|
@ -52,13 +50,11 @@ export function Feed({
|
|||
const firstItem = data?.pages[0]?.items[0]
|
||||
|
||||
// mark all read on fresh data
|
||||
// (this will fire each time firstItem changes)
|
||||
React.useEffect(() => {
|
||||
let cleanup
|
||||
if (firstItem) {
|
||||
const to = setTimeout(() => markAllRead(), 250)
|
||||
cleanup = () => clearTimeout(to)
|
||||
markAllRead()
|
||||
}
|
||||
return cleanup
|
||||
}, [firstItem, markAllRead])
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
|
@ -83,7 +79,7 @@ export function Feed({
|
|||
const onRefresh = React.useCallback(async () => {
|
||||
try {
|
||||
setIsPTRing(true)
|
||||
await refetch()
|
||||
await checkUnread({invalidate: true})
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh notifications feed', {
|
||||
error: err,
|
||||
|
@ -91,7 +87,7 @@ export function Feed({
|
|||
} finally {
|
||||
setIsPTRing(false)
|
||||
}
|
||||
}, [refetch, setIsPTRing])
|
||||
}, [checkUnread, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
|
@ -136,21 +132,6 @@ export function Feed({
|
|||
[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(
|
||||
() =>
|
||||
isFetchingNextPage ? (
|
||||
|
@ -180,7 +161,7 @@ export function Feed({
|
|||
data={items}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={FeedHeader}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={FeedFooter}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
|
|
|
@ -19,7 +19,10 @@ import {logger} from '#/logger'
|
|||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
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 {listenSoftReset, emitSoftReset} from '#/state/events'
|
||||
|
||||
|
@ -35,8 +38,9 @@ export function NotificationsScreen({}: Props) {
|
|||
const {screen} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const unreadNotifs = useUnreadNotifications()
|
||||
const queryClient = useQueryClient()
|
||||
const unreadNotifs = useUnreadNotifications()
|
||||
const unreadApi = useUnreadNotificationsApi()
|
||||
const hasNew = !!unreadNotifs
|
||||
|
||||
// event handlers
|
||||
|
@ -48,10 +52,16 @@ export function NotificationsScreen({}: Props) {
|
|||
|
||||
const onPressLoadLatest = React.useCallback(() => {
|
||||
scrollToTop()
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: NOTIFS_RQKEY(),
|
||||
})
|
||||
}, [scrollToTop, queryClient])
|
||||
if (hasNew) {
|
||||
// render what we have now
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: NOTIFS_RQKEY(),
|
||||
})
|
||||
} else {
|
||||
// check with the server
|
||||
unreadApi.checkUnread({invalidate: true})
|
||||
}
|
||||
}, [scrollToTop, queryClient, unreadApi, hasNew])
|
||||
|
||||
// on-visible setup
|
||||
// =
|
||||
|
|
Loading…
Reference in New Issue