diff options
Diffstat (limited to 'src/state/queries/notifications/util.ts')
-rw-r--r-- | src/state/queries/notifications/util.ts | 219 |
1 files changed, 219 insertions, 0 deletions
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts new file mode 100644 index 000000000..b8f320473 --- /dev/null +++ b/src/state/queries/notifications/util.ts @@ -0,0 +1,219 @@ +import { + AppBskyNotificationListNotifications, + 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 +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 +} + +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 && + notif.isRead === groupedNotif.notification.isRead + ) { + 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) +} |