diff options
Diffstat (limited to 'src/state/models/feeds/notifications.ts')
-rw-r--r-- | src/state/models/feeds/notifications.ts | 671 |
1 files changed, 0 insertions, 671 deletions
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts deleted file mode 100644 index 607e3038b..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' - -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 + 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() { - 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 && - 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<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 -} |