diff options
Diffstat (limited to 'src/state/queries/notifications')
-rw-r--r-- | src/state/queries/notifications/feed.ts | 212 | ||||
-rw-r--r-- | src/state/queries/notifications/unread.tsx | 113 | ||||
-rw-r--r-- | src/state/queries/notifications/util.ts | 38 |
3 files changed, 363 insertions, 0 deletions
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts new file mode 100644 index 000000000..9d491c3a4 --- /dev/null +++ b/src/state/queries/notifications/feed.ts @@ -0,0 +1,212 @@ +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedLike, + AppBskyNotificationListNotifications, + BskyAgent, +} from '@atproto/api' +import chunk from 'lodash.chunk' +import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' +import {useSession} from '../../session' +import {useModerationOpts} from '../preferences' +import {shouldFilterNotif} from './util' +import {useMutedThreads} from '#/state/muted-threads' + +const GROUPABLE_REASONS = ['like', 'repost', 'follow'] +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 {agent} = useSession() + const moderationOpts = useModerationOpts() + const threadMutes = useMutedThreads() + const enabled = opts?.enabled !== false + + return useInfiniteQuery< + FeedPage, + Error, + InfiniteData<FeedPage>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await agent.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(agent, notifsGrouped) + for (const notif of notifsGrouped) { + if (notif.subjectUri) { + notif.subject = subjects.get(notif.subjectUri) + } + } + + // apply thread muting + notifsGrouped = notifsGrouped.filter( + notif => !isThreadMuted(notif, threadMutes), + ) + + return { + cursor: res.data.cursor, + items: notifsGrouped, + } + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled, + }) +} + +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( + agent: BskyAgent, + 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 => + agent.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) +} diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx new file mode 100644 index 000000000..91aa6f3c2 --- /dev/null +++ b/src/state/queries/notifications/unread.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import * as Notifications from 'expo-notifications' +import BroadcastChannel from '#/lib/broadcast' +import {useSession} from '#/state/session' +import {useModerationOpts} from '../preferences' +import {shouldFilterNotif} from './util' +import {isNative} from '#/platform/detection' + +const UPDATE_INTERVAL = 30 * 1e3 // 30sec + +const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL') + +type StateContext = string + +interface ApiContext { + markAllRead: () => Promise<void> + checkUnread: () => Promise<void> +} + +const stateContext = React.createContext<StateContext>('') + +const apiContext = React.createContext<ApiContext>({ + async markAllRead() {}, + async checkUnread() {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const {hasSession, agent} = useSession() + const moderationOpts = useModerationOpts() + + const [numUnread, setNumUnread] = React.useState('') + + const checkUnreadRef = React.useRef<(() => Promise<void>) | null>(null) + const lastSyncRef = React.useRef<Date>(new Date()) + + // periodic sync + React.useEffect(() => { + if (!hasSession || !checkUnreadRef.current) { + return + } + checkUnreadRef.current() // fire on init + const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL) + return () => clearInterval(interval) + }, [hasSession]) + + // listen for broadcasts + React.useEffect(() => { + const listener = ({data}: MessageEvent) => { + lastSyncRef.current = new Date() + setNumUnread(data.event) + } + broadcast.addEventListener('message', listener) + return () => { + broadcast.removeEventListener('message', listener) + } + }, [setNumUnread]) + + // create API + const api = React.useMemo<ApiContext>(() => { + return { + async markAllRead() { + // update server + await agent.updateSeenNotifications(lastSyncRef.current.toISOString()) + + // update & broadcast + setNumUnread('') + broadcast.postMessage({event: ''}) + }, + + async checkUnread() { + // count + const res = await agent.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)) + } + + // 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}) + }, + } + }, [setNumUnread, agent, moderationOpts]) + checkUnreadRef.current = api.checkUnread + + return ( + <stateContext.Provider value={numUnread}> + <apiContext.Provider value={api}>{children}</apiContext.Provider> + </stateContext.Provider> + ) +} + +export function useUnreadNotifications() { + return React.useContext(stateContext) +} + +export function useUnreadNotificationsApi() { + return React.useContext(apiContext) +} diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts new file mode 100644 index 000000000..c49d1851a --- /dev/null +++ b/src/state/queries/notifications/util.ts @@ -0,0 +1,38 @@ +import { + AppBskyNotificationListNotifications, + ModerationOpts, + moderateProfile, + moderatePost, +} from '@atproto/api' + +// TODO this should be in the sdk as moderateNotification -prf +export function shouldFilterNotif( + notif: AppBskyNotificationListNotifications.Notification, + moderationOpts: ModerationOpts | undefined, +): boolean { + if (!moderationOpts) { + return false + } + const profile = moderateProfile(notif.author, moderationOpts) + if ( + profile.account.filter || + profile.profile.filter || + notif.author.viewer?.muted + ) { + return true + } + if ( + notif.type === 'reply' || + notif.type === 'quote' || + notif.type === 'mention' + ) { + // NOTE: the notification overlaps the post enough for this to work + const post = moderatePost(notif, moderationOpts) + if (post.content.filter) { + return true + } + } + // TODO: thread muting is not being applied + // (this requires fetching the post) + return false +} |