Refactor notifications to use react-query (#1878)

* Move broadcast channel to lib

* Refactor view/com/post/Post and remove temporary 2 components

* Add useModerationOpts hook

* Refactor notifications to use react-query

* Fix: only trigger updates in useModerationOpts when the values have changed

* Implement unread notification tracking

* Add moderation filtering to notifications

* Handle native/push notifications

* Remove dead code

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Paul Frazee 2023-11-12 18:13:11 -08:00 committed by GitHub
parent c584a3378d
commit b445c15cc9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 941 additions and 1739 deletions

View file

@ -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
}

View file

@ -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() {

View file

@ -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(

View file

@ -1,6 +0,0 @@
export default class BroadcastChannel {
constructor(public name: string) {}
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
}

View file

@ -1 +0,0 @@
export default BroadcastChannel

View file

@ -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'

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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()