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,
|
* 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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
// =
|
// =
|
||||||
|
|
Loading…
Reference in New Issue