about summary refs log tree commit diff
path: root/src/state/models/feeds
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models/feeds')
-rw-r--r--src/state/models/feeds/notifications.ts671
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 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
-}