about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/models/feeds/notifications.ts671
-rw-r--r--src/state/models/me.ts20
-rw-r--r--src/state/models/root-store.ts8
-rw-r--r--src/state/persisted/broadcast/index.ts6
-rw-r--r--src/state/persisted/broadcast/index.web.ts1
-rw-r--r--src/state/persisted/index.ts2
-rw-r--r--src/state/queries/notifications/feed.ts212
-rw-r--r--src/state/queries/notifications/unread.tsx113
-rw-r--r--src/state/queries/notifications/util.ts38
-rw-r--r--src/state/queries/preferences/index.ts32
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()