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' 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 { this.rootStore.log.warn('Received an invalid record', { record: v, error: valid.error, }) return } } } this.rootStore.log.warn( 'app.bsky.notifications.list served an unsupported record type', 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 + this.rootStore.invitedUsers.numNotifs if (count >= MAX_VISIBLE_NOTIFS) { return `${MAX_VISIBLE_NOTIFS}+` } if (count === 0) { return '' } return String(count) } // public api // = /** * Nuke all data */ clear() { this.rootStore.log.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) => { this.rootStore.log.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 () => { this.rootStore.log.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) { this.rootStore.log.error('NotificationsModel:syncQueue failed', {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 => { this.rootStore.log.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) { this.rootStore.log.warn('Failed to update notifications read state', 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) { this.rootStore.log.error('Failed to fetch notifications', error) } if (loadMoreError) { this.rootStore.log.error( 'Failed to load more notifications', 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 && this.rootStore.mutedThreads.uris.has(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 { // construct item models and track who needs more data const itemModels: NotificationsFeedItemModel[] = [] const addedPostMap = new Map() 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 { 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 }