diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/models/feeds/notifications.ts | 671 | ||||
-rw-r--r-- | src/state/models/me.ts | 20 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 8 | ||||
-rw-r--r-- | src/state/persisted/broadcast/index.ts | 6 | ||||
-rw-r--r-- | src/state/persisted/broadcast/index.web.ts | 1 | ||||
-rw-r--r-- | src/state/persisted/index.ts | 2 | ||||
-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 | ||||
-rw-r--r-- | src/state/queries/preferences/index.ts | 32 |
10 files changed, 394 insertions, 709 deletions
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts deleted file mode 100644 index 5f34feb66..000000000 --- a/src/state/models/feeds/notifications.ts +++ /dev/null @@ -1,671 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyNotificationListNotifications as ListNotifications, - AppBskyActorDefs, - AppBskyFeedDefs, - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyGraphFollow, - ComAtprotoLabelDefs, - moderatePost, - moderateProfile, -} from '@atproto/api' -import AwaitLock from 'await-lock' -import chunk from 'lodash.chunk' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from '../root-store' -import {PostThreadModel} from '../content/post-thread' -import {cleanError} from 'lib/strings/errors' -import {logger} from '#/logger' -import {isThreadMuted} 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 - -export const MAX_VISIBLE_NOTIFS = 30 - -export interface GroupedNotification extends ListNotifications.Notification { - additional?: ListNotifications.Notification[] -} - -type SupportedRecord = - | AppBskyFeedPost.Record - | AppBskyFeedRepost.Record - | AppBskyFeedLike.Record - | AppBskyGraphFollow.Record - -export class NotificationsFeedItemModel { - // ui state - _reactKey: string = '' - - // data - uri: string = '' - cid: string = '' - author: AppBskyActorDefs.ProfileViewBasic = { - did: '', - handle: '', - avatar: '', - } - reason: string = '' - reasonSubject?: string - record?: SupportedRecord - isRead: boolean = false - indexedAt: string = '' - labels?: ComAtprotoLabelDefs.Label[] - additional?: NotificationsFeedItemModel[] - - // additional data - additionalPost?: PostThreadModel - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - v: GroupedNotification, - ) { - makeAutoObservable(this, {rootStore: false}) - this._reactKey = reactKey - this.copy(v) - } - - copy(v: GroupedNotification, preserve = false) { - this.uri = v.uri - this.cid = v.cid - this.author = v.author - this.reason = v.reason - this.reasonSubject = v.reasonSubject - this.record = this.toSupportedRecord(v.record) - this.isRead = v.isRead - this.indexedAt = v.indexedAt - this.labels = v.labels - if (v.additional?.length) { - this.additional = [] - for (const add of v.additional) { - this.additional.push( - new NotificationsFeedItemModel(this.rootStore, '', add), - ) - } - } else if (!preserve) { - this.additional = undefined - } - } - - get shouldFilter(): boolean { - if (this.additionalPost?.thread) { - const postMod = moderatePost( - this.additionalPost.thread.data.post, - this.rootStore.preferences.moderationOpts, - ) - return postMod.content.filter || false - } - const profileMod = moderateProfile( - this.author, - this.rootStore.preferences.moderationOpts, - ) - return profileMod.account.filter || false - } - - get numUnreadInGroup(): number { - if (this.additional?.length) { - return ( - this.additional.reduce( - (acc, notif) => acc + notif.numUnreadInGroup, - 0, - ) + (this.isRead ? 0 : 1) - ) - } - return this.isRead ? 0 : 1 - } - - markGroupRead() { - if (this.additional?.length) { - for (const notif of this.additional) { - notif.markGroupRead() - } - } - this.isRead = true - } - - get isLike() { - return this.reason === 'like' && !this.isCustomFeedLike // the reason property for custom feed likes is also 'like' - } - - get isRepost() { - return this.reason === 'repost' - } - - get isMention() { - return this.reason === 'mention' - } - - get isReply() { - return this.reason === 'reply' - } - - get isQuote() { - return this.reason === 'quote' - } - - get isFollow() { - return this.reason === 'follow' - } - - get isCustomFeedLike() { - return ( - this.reason === 'like' && this.reasonSubject?.includes('feed.generator') - ) - } - - get needsAdditionalData() { - if ( - this.isLike || - this.isRepost || - this.isReply || - this.isQuote || - this.isMention - ) { - return !this.additionalPost - } - return false - } - - get additionalDataUri(): string | undefined { - if (this.isReply || this.isQuote || this.isMention) { - return this.uri - } else if (this.isLike || this.isRepost) { - return this.subjectUri - } - } - - get subjectUri(): string { - if (this.reasonSubject) { - return this.reasonSubject - } - const record = this.record - if ( - AppBskyFeedRepost.isRecord(record) || - AppBskyFeedLike.isRecord(record) - ) { - return record.subject.uri - } - return '' - } - - get reasonSubjectRootUri(): string | undefined { - if (this.additionalPost) { - return this.additionalPost.rootUri - } - return undefined - } - - toSupportedRecord(v: unknown): SupportedRecord | undefined { - for (const ns of [ - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyGraphFollow, - ]) { - if (ns.isRecord(v)) { - const valid = ns.validateRecord(v) - if (valid.success) { - return v - } else { - logger.warn('Received an invalid record', { - record: v, - error: valid.error, - }) - return - } - } - } - logger.warn( - 'app.bsky.notifications.list served an unsupported record type', - {record: v}, - ) - } - - setAdditionalData(additionalPost: AppBskyFeedDefs.PostView) { - if (this.additionalPost) { - this.additionalPost._replaceAll({ - success: true, - headers: {}, - data: { - thread: { - post: additionalPost, - }, - }, - }) - } else { - this.additionalPost = PostThreadModel.fromPostView( - this.rootStore, - additionalPost, - ) - } - } -} - -export class NotificationsFeedModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreError = '' - hasMore = true - loadMoreCursor?: string - - /** - * The last time notifications were seen. Refers to either the - * user's machine clock or the value of the `indexedAt` property on their - * latest notification, whichever was greater at the time of viewing. - */ - lastSync?: Date - - // used to linearize async modifications to state - lock = new AwaitLock() - - // data - notifications: NotificationsFeedItemModel[] = [] - queuedNotifications: undefined | NotificationsFeedItemModel[] = undefined - unreadCount = 0 - - // this is used to help trigger push notifications - mostRecentNotificationUri: string | undefined - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - mostRecentNotificationUri: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.notifications.length !== 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get hasNewLatest() { - return Boolean( - this.queuedNotifications && this.queuedNotifications?.length > 0, - ) - } - - get unreadCountLabel(): string { - const count = this.unreadCount - if (count >= MAX_VISIBLE_NOTIFS) { - return `${MAX_VISIBLE_NOTIFS}+` - } - if (count === 0) { - return '' - } - return String(count) - } - - // public api - // = - - /** - * Nuke all data - */ - clear() { - logger.debug('NotificationsModel:clear') - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.loadMoreCursor = undefined - this.notifications = [] - this.unreadCount = 0 - this.rootStore.emitUnreadNotifications(0) - this.mostRecentNotificationUri = undefined - } - - /** - * Load for first render - */ - setup = bundleAsync(async (isRefreshing: boolean = false) => { - logger.debug('NotificationsModel:refresh', {isRefreshing}) - await this.lock.acquireAsync() - try { - this._xLoading(isRefreshing) - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - }) - await this._replaceAll(res) - this._setQueued(undefined) - this._countUnread() - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } finally { - this.lock.release() - } - }) - - /** - * Reset and load - */ - async refresh() { - this.isRefreshing = true // set optimistically for UI - return this.setup(true) - } - - /** - * Sync the next set of notifications to show - */ - syncQueue = bundleAsync(async () => { - logger.debug('NotificationsModel:syncQueue') - if (this.unreadCount >= MAX_VISIBLE_NOTIFS) { - return // no need to check - } - await this.lock.acquireAsync() - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - }) - - const queue = [] - for (const notif of res.data.notifications) { - if (this.notifications.length) { - if (isEq(notif, this.notifications[0])) { - break - } - } else { - if (!notif.isRead) { - break - } - } - queue.push(notif) - } - - // NOTE - // because filtering depends on the added information we have to fetch - // the full models here. this is *not* ideal performance and we need - // to update the notifications route to give all the info we need - // -prf - const queueModels = await this._fetchItemModels(queue) - this._setQueued(this._filterNotifications(queueModels)) - this._countUnread() - } catch (e) { - logger.error('NotificationsModel:syncQueue failed', { - error: e, - }) - } finally { - this.lock.release() - } - - // if there are no notifications, we should refresh the list - // this will only run for new users who have no notifications - // NOTE: needs to be after the lock is released - if (this.isEmpty) { - this.refresh() - } - }) - - /** - * Load more posts to the end of the notifications - */ - loadMore = bundleAsync(async () => { - if (!this.hasMore) { - return - } - await this.lock.acquireAsync() - try { - this._xLoading() - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - cursor: this.loadMoreCursor, - }) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(undefined, e) - runInAction(() => { - this.hasMore = false - }) - } - } finally { - this.lock.release() - } - }) - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - // unread notification in-place - // = - async update() { - const promises = [] - for (const item of this.notifications) { - if (item.additionalPost) { - promises.push(item.additionalPost.update()) - } - } - await Promise.all(promises).catch(e => { - logger.error('Uncaught failure during notifications update()', e) - }) - } - - /** - * Update read/unread state - */ - async markAllRead() { - try { - for (const notif of this.notifications) { - notif.markGroupRead() - } - this._countUnread() - await this.rootStore.agent.updateSeenNotifications( - this.lastSync ? this.lastSync.toISOString() : undefined, - ) - } catch (e: any) { - logger.warn('Failed to update notifications read state', { - error: e, - }) - } - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(error?: any, loadMoreError?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(error) - this.loadMoreError = cleanError(loadMoreError) - if (error) { - logger.error('Failed to fetch notifications', {error}) - } - if (loadMoreError) { - logger.error('Failed to load more notifications', { - error: loadMoreError, - }) - } - } - - // helper functions - // = - - async _replaceAll(res: ListNotifications.Response) { - const latest = res.data.notifications[0] - - if (latest) { - const now = new Date() - const lastIndexed = new Date(latest.indexedAt) - const nowOrLastIndexed = now > lastIndexed ? now : lastIndexed - - this.mostRecentNotificationUri = latest.uri - this.lastSync = nowOrLastIndexed - } - - return this._appendAll(res, true) - } - - async _appendAll(res: ListNotifications.Response, replace = false) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - const itemModels = await this._processNotifications(res.data.notifications) - runInAction(() => { - if (replace) { - this.notifications = itemModels - } else { - this.notifications = this.notifications.concat(itemModels) - } - }) - } - - _filterNotifications( - items: NotificationsFeedItemModel[], - ): NotificationsFeedItemModel[] { - return items - .filter(item => { - const hideByLabel = item.shouldFilter - let mutedThread = !!( - item.reasonSubjectRootUri && isThreadMuted(item.reasonSubjectRootUri) - ) - return !hideByLabel && !mutedThread - }) - .map(item => { - if (item.additional?.length) { - item.additional = this._filterNotifications(item.additional) - } - return item - }) - } - - async _fetchItemModels( - items: ListNotifications.Notification[], - ): Promise<NotificationsFeedItemModel[]> { - // construct item models and track who needs more data - const itemModels: NotificationsFeedItemModel[] = [] - const addedPostMap = new Map<string, NotificationsFeedItemModel[]>() - for (const item of items) { - const itemModel = new NotificationsFeedItemModel( - this.rootStore, - `notification-${item.uri}`, - item, - ) - const uri = itemModel.additionalDataUri - if (uri) { - const models = addedPostMap.get(uri) || [] - models.push(itemModel) - addedPostMap.set(uri, models) - } - itemModels.push(itemModel) - } - - // fetch additional data - if (addedPostMap.size > 0) { - const uriChunks = chunk(Array.from(addedPostMap.keys()), 25) - const postsChunks = await Promise.all( - uriChunks.map(uris => - this.rootStore.agent.app.bsky.feed - .getPosts({uris}) - .then(res => res.data.posts), - ), - ) - for (const post of postsChunks.flat()) { - this.rootStore.posts.set(post.uri, post) - const models = addedPostMap.get(post.uri) - if (models?.length) { - for (const model of models) { - model.setAdditionalData(post) - } - } - } - } - - return itemModels - } - - async _processNotifications( - items: ListNotifications.Notification[], - ): Promise<NotificationsFeedItemModel[]> { - const itemModels = await this._fetchItemModels(groupNotifications(items)) - return this._filterNotifications(itemModels) - } - - _setQueued(queued: undefined | NotificationsFeedItemModel[]) { - this.queuedNotifications = queued - } - - _countUnread() { - let unread = 0 - for (const notif of this.notifications) { - unread += notif.numUnreadInGroup - } - if (this.queuedNotifications) { - unread += this.queuedNotifications.filter(notif => !notif.isRead).length - } - this.unreadCount = unread - this.rootStore.emitUnreadNotifications(unread) - } -} - -function groupNotifications( - items: ListNotifications.Notification[], -): GroupedNotification[] { - const items2: GroupedNotification[] = [] - for (const item of items) { - const ts = +new Date(item.indexedAt) - let grouped = false - if (GROUPABLE_REASONS.includes(item.reason)) { - for (const item2 of items2) { - const ts2 = +new Date(item2.indexedAt) - if ( - Math.abs(ts2 - ts) < MS_2DAY && - item.reason === item2.reason && - item.reasonSubject === item2.reasonSubject && - item.author.did !== item2.author.did - ) { - item2.additional = item2.additional || [] - item2.additional.push(item) - grouped = true - break - } - } - } - if (!grouped) { - items2.push(item) - } - } - return items2 -} - -type N = ListNotifications.Notification | NotificationsFeedItemModel -function isEq(a: N, b: N) { - // this function has a key subtlety- the indexedAt comparison - // the reason for this is reposts: they set the URI of the original post, not of the repost record - // the indexedAt time will be for the repost however, so we use that to help us - return a.uri === b.uri && a.indexedAt === b.indexedAt -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index c17fcf183..427b0e35e 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -4,13 +4,11 @@ import { ComAtprotoServerListAppPasswords, } from '@atproto/api' import {RootStoreModel} from './root-store' -import {NotificationsFeedModel} from './feeds/notifications' import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' import {logger} from '#/logger' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min -const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec export class MeModel { did: string = '' @@ -20,12 +18,10 @@ export class MeModel { avatar: string = '' followsCount: number | undefined followersCount: number | undefined - notifications: NotificationsFeedModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] lastProfileStateUpdate = Date.now() - lastNotifsUpdate = Date.now() get invitesAvailable() { return this.invites.filter(isInviteAvailable).length @@ -37,12 +33,10 @@ export class MeModel { {rootStore: false, serialize: false, hydrate: false}, {autoBind: true}, ) - this.notifications = new NotificationsFeedModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) } clear() { - this.notifications.clear() this.follows.clear() this.rootStore.profiles.cache.clear() this.rootStore.posts.cache.clear() @@ -99,16 +93,6 @@ export class MeModel { if (sess.hasSession) { this.did = sess.currentSession?.did || '' await this.fetchProfile() - /* dont await */ this.notifications.setup().catch(e => { - logger.error('Failed to setup notifications model', { - error: e, - }) - }) - /* dont await */ this.notifications.setup().catch(e => { - logger.error('Failed to setup notifications model', { - error: e, - }) - }) this.rootStore.emitSessionLoaded() await this.fetchInviteCodes() await this.fetchAppPasswords() @@ -125,10 +109,6 @@ export class MeModel { await this.fetchInviteCodes() await this.fetchAppPasswords() } - if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) { - this.lastNotifsUpdate = Date.now() - await this.notifications.syncQueue() - } } async fetchProfile() { diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index c07cf3078..288e8b8e1 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -203,14 +203,6 @@ export class RootStoreModel { emitScreenSoftReset() { DeviceEventEmitter.emit('screen-soft-reset') } - - // the unread notifications count has changed - onUnreadNotifications(handler: (count: number) => void): EmitterSubscription { - return DeviceEventEmitter.addListener('unread-notifications', handler) - } - emitUnreadNotifications(count: number) { - DeviceEventEmitter.emit('unread-notifications', count) - } } const throwawayInst = new RootStoreModel( diff --git a/src/state/persisted/broadcast/index.ts b/src/state/persisted/broadcast/index.ts deleted file mode 100644 index e0e7f724b..000000000 --- a/src/state/persisted/broadcast/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default class BroadcastChannel { - constructor(public name: string) {} - postMessage(_data: any) {} - close() {} - onmessage: (event: MessageEvent) => void = () => {} -} diff --git a/src/state/persisted/broadcast/index.web.ts b/src/state/persisted/broadcast/index.web.ts deleted file mode 100644 index 33b3548ad..000000000 --- a/src/state/persisted/broadcast/index.web.ts +++ /dev/null @@ -1 +0,0 @@ -export default BroadcastChannel diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index a5c38513f..f6eff4257 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -3,7 +3,7 @@ import {logger} from '#/logger' import {defaults, Schema} from '#/state/persisted/schema' import {migrate} from '#/state/persisted/legacy' import * as store from '#/state/persisted/store' -import BroadcastChannel from '#/state/persisted/broadcast' +import BroadcastChannel from '#/lib/broadcast' export type {Schema, PersistedAccount} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' 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 +} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index d64bbd954..4f10b01a6 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,5 +1,11 @@ +import {useEffect, useState} from 'react' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' -import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' +import { + LabelPreference, + BskyFeedViewPreference, + ModerationOpts, +} from '@atproto/api' +import isEqual from 'lodash.isequal' import {track} from '#/lib/analytics/analytics' import {getAge} from '#/lib/strings/time' @@ -15,6 +21,7 @@ import { DEFAULT_HOME_FEED_PREFS, DEFAULT_THREAD_VIEW_PREFS, } from '#/state/queries/preferences/const' +import {getModerationOpts} from '#/state/queries/preferences/moderation' export * from '#/state/queries/preferences/types' export * from '#/state/queries/preferences/moderation' @@ -23,7 +30,7 @@ export * from '#/state/queries/preferences/const' export const usePreferencesQueryKey = ['getPreferences'] export function usePreferencesQuery() { - const {agent} = useSession() + const {agent, hasSession} = useSession() return useQuery({ queryKey: usePreferencesQueryKey, queryFn: async () => { @@ -76,9 +83,30 @@ export function usePreferencesQuery() { } return preferences }, + enabled: hasSession, }) } +export function useModerationOpts() { + const {currentAccount} = useSession() + const [opts, setOpts] = useState<ModerationOpts | undefined>() + const prefs = usePreferencesQuery() + useEffect(() => { + if (!prefs.data) { + return + } + // only update this hook when the moderation options change + const newOpts = getModerationOpts({ + userDid: currentAccount?.did || '', + preferences: prefs.data, + }) + if (!isEqual(opts, newOpts)) { + setOpts(newOpts) + } + }, [prefs.data, currentAccount, opts, setOpts]) + return opts +} + export function useClearPreferencesMutation() { const {agent} = useSession() const queryClient = useQueryClient() |