diff --git a/src/App.native.tsx b/src/App.native.tsx
index 8479465fd2..e976fce4fa 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -31,6 +31,7 @@ import {
useSession,
useSessionApi,
} from 'state/session'
+import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
@@ -53,7 +54,7 @@ const InnerApp = observer(function AppImpl() {
setupState().then(store => {
setRootStore(store)
analytics.init(store)
- notifications.init(store)
+ notifications.init(store, queryClient)
store.onSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
})
@@ -72,22 +73,20 @@ const InnerApp = observer(function AppImpl() {
}
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
})
@@ -103,19 +102,23 @@ function App() {
}
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 81e03d0798..5967fb7518 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -29,6 +29,7 @@ import {
useSession,
useSessionApi,
} from 'state/session'
+import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
const InnerApp = observer(function AppImpl() {
@@ -60,22 +61,20 @@ const InnerApp = observer(function AppImpl() {
}
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
})
@@ -91,19 +90,23 @@ function App() {
}
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 381f33cf94..fb88dc84f8 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -1,7 +1,6 @@
import * as React from 'react'
import {StyleSheet} from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
-import {observer} from 'mobx-react-lite'
import {
NavigationContainer,
createNavigationContainerRef,
@@ -33,10 +32,10 @@ import {isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from './state'
import {bskyTitle} from 'lib/strings/headings'
import {JSX} from 'react/jsx-runtime'
import {timeout} from 'lib/async/timeout'
+import {useUnreadNotifications} from './state/queries/notifications/unread'
import {HomeScreen} from './view/screens/Home'
import {SearchScreen} from './view/screens/Search'
@@ -346,7 +345,7 @@ function NotificationsTabNavigator() {
)
}
-const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
+function MyProfileTabNavigator() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
return (
)
-})
+}
/**
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
*/
-const FlatNavigator = observer(function FlatNavigatorImpl() {
+const FlatNavigator = () => {
const pal = usePalette('default')
- const store = useStores()
- const unreadCountLabel = store.me.notifications.unreadCountLabel
+ const numUnread = useUnreadNotifications()
- const title = (page: string) => bskyTitle(page, unreadCountLabel)
+ const title = (page: string) => bskyTitle(page, numUnread)
return (
NotificationsScreen}
options={{title: title('Notifications')}}
/>
- {commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
+ {commonScreens(Flat as typeof HomeTab, numUnread)}
)
-})
+}
/**
* The RoutesContainer should wrap all components which need access
diff --git a/src/state/persisted/broadcast/index.ts b/src/lib/broadcast/index.ts
similarity index 50%
rename from src/state/persisted/broadcast/index.ts
rename to src/lib/broadcast/index.ts
index e0e7f724b7..aa3aef5801 100644
--- a/src/state/persisted/broadcast/index.ts
+++ b/src/lib/broadcast/index.ts
@@ -3,4 +3,9 @@ export default class BroadcastChannel {
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
+ addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
+ removeEventListener(
+ _type: string,
+ _listener: (event: MessageEvent) => void,
+ ) {}
}
diff --git a/src/state/persisted/broadcast/index.web.ts b/src/lib/broadcast/index.web.ts
similarity index 100%
rename from src/state/persisted/broadcast/index.web.ts
rename to src/lib/broadcast/index.web.ts
diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts
index c5c7a5ca16..129023f717 100644
--- a/src/lib/hooks/useSetTitle.ts
+++ b/src/lib/hooks/useSetTitle.ts
@@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
-import {useStores} from 'state/index'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
-/**
- * Requires consuming component to be wrapped in `observer`:
- * https://stackoverflow.com/a/71488009
- */
export function useSetTitle(title?: string) {
const navigation = useNavigation()
- const {unreadCountLabel} = useStores().me.notifications
+ const numUnread = useUnreadNotifications()
useEffect(() => {
if (title) {
- navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
+ navigation.setOptions({title: bskyTitle(title, numUnread)})
}
- }, [title, navigation, unreadCountLabel])
+ }, [title, navigation, numUnread])
}
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 73f9c56f60..d46479a05b 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -1,18 +1,18 @@
import * as Notifications from 'expo-notifications'
+import {QueryClient} from '@tanstack/react-query'
import {RootStoreModel} from '../../state'
import {resetToTab} from '../../Navigation'
import {devicePlatform, isIOS} from 'platform/detection'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
+import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
const SERVICE_DID = (serviceUrl?: string) =>
serviceUrl?.includes('staging')
? 'did:web:api.staging.bsky.dev'
: 'did:web:api.bsky.app'
-export function init(store: RootStoreModel) {
- store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
-
+export function init(store: RootStoreModel, queryClient: QueryClient) {
store.onSessionLoaded(async () => {
// request notifications permission once the user has logged in
const perms = await Notifications.getPermissionsAsync()
@@ -83,7 +83,7 @@ export function init(store: RootStoreModel) {
)
if (event.request.trigger.type === 'push') {
// refresh notifications in the background
- store.me.notifications.syncQueue()
+ queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
// handle payload-based deeplinks
let payload
if (isIOS) {
@@ -121,7 +121,7 @@ export function init(store: RootStoreModel) {
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
- store.me.notifications.refresh() // refresh notifications
+ queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
resetToTab('NotificationsTab') // open notifications tab
}
},
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
deleted file mode 100644
index 5f34feb662..0000000000
--- 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 {
- // 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
-}
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index c17fcf183d..427b0e35e9 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 c07cf30781..288e8b8e11 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/index.ts b/src/state/persisted/index.ts
index a5c38513f3..f6eff42579 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 0000000000..9d491c3a49
--- /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,
+ 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