Activity notification settings (#8485)

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: hailey <me@haileyok.com>
This commit is contained in:
Samuel Newman
2025-07-02 00:36:04 +03:00
committed by GitHub
parent 8f9a8ddce0
commit bc072570d2
53 changed files with 1810 additions and 288 deletions
+10 -3
View File
@@ -1,9 +1,10 @@
import {useEffect, useMemo, useState} from 'react'
import {type AppBskyActorDefs} from '@atproto/api'
import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api'
import {type QueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions'
import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search'
import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
@@ -33,6 +34,7 @@ export interface ProfileShadow {
blockingUri: string | undefined
verification: AppBskyActorDefs.VerificationState
status: AppBskyActorDefs.StatusView | undefined
activitySubscription: AppBskyNotificationDefs.ActivitySubscription | undefined
}
const shadows: WeakMap<
@@ -114,8 +116,8 @@ export function updateProfileShadow(
value: Partial<ProfileShadow>,
) {
const cachedProfiles = findProfilesInCache(queryClient, did)
for (let post of cachedProfiles) {
shadows.set(post, {...shadows.get(post), ...value})
for (let profile of cachedProfiles) {
shadows.set(profile, {...shadows.get(profile), ...value})
}
batchedUpdates(() => {
emitter.emit(did, value)
@@ -137,6 +139,10 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
blocking:
'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
activitySubscription:
'activitySubscription' in shadow
? shadow.activitySubscription
: profile.viewer?.activitySubscription,
},
verification:
'verification' in shadow ? shadow.verification : profile.verification,
@@ -171,4 +177,5 @@ function* findProfilesInCache(
yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
}
+130
View File
@@ -0,0 +1,130 @@
import {
type AppBskyActorDefs,
type AppBskyNotificationDeclaration,
type AppBskyNotificationListActivitySubscriptions,
} from '@atproto/api'
import {t} from '@lingui/macro'
import {
type InfiniteData,
type QueryClient,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
export const RQKEY_getActivitySubscriptions = ['activity-subscriptions']
export const RQKEY_getNotificationDeclaration = ['notification-declaration']
export function useActivitySubscriptionsQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: RQKEY_getActivitySubscriptions,
queryFn: async ({pageParam}) => {
const response =
await agent.app.bsky.notification.listActivitySubscriptions({
cursor: pageParam,
})
return response.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: prev => prev.cursor,
})
}
export function useNotificationDeclarationQuery() {
const agent = useAgent()
const {currentAccount} = useSession()
return useQuery({
queryKey: RQKEY_getNotificationDeclaration,
queryFn: async () => {
try {
const response = await agent.app.bsky.notification.declaration.get({
repo: currentAccount!.did,
rkey: 'self',
})
return response
} catch (err) {
if (
err instanceof Error &&
err.message.startsWith('Could not locate record')
) {
return {
value: {
$type: 'app.bsky.notification.declaration',
allowSubscriptions: 'followers',
} satisfies AppBskyNotificationDeclaration.Record,
}
} else {
throw err
}
}
},
})
}
export function useNotificationDeclarationMutation() {
const agent = useAgent()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (record: AppBskyNotificationDeclaration.Record) => {
const response = await agent.app.bsky.notification.declaration.put(
{
repo: currentAccount!.did,
rkey: 'self',
},
record,
)
return response
},
onMutate: value => {
queryClient.setQueryData(
RQKEY_getNotificationDeclaration,
(old?: {
uri: string
cid: string
value: AppBskyNotificationDeclaration.Record
}) => {
if (!old) return old
return {
value,
}
},
)
},
onError: () => {
Toast.show(t`Failed to update notification declaration`)
queryClient.invalidateQueries({
queryKey: RQKEY_getNotificationDeclaration,
})
},
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>
>({
queryKey: RQKEY_getActivitySubscriptions,
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData.pages) {
for (const subscription of page.subscriptions) {
if (subscription.did === did) {
yield subscription
}
}
}
}
}
+14 -19
View File
@@ -1,13 +1,13 @@
import {
AppBskyActorDefs,
AppBskyGraphDefs,
AppBskyGraphGetList,
BskyAgent,
type AppBskyActorDefs,
type AppBskyGraphDefs,
type AppBskyGraphGetList,
type BskyAgent,
} from '@atproto/api'
import {
InfiniteData,
QueryClient,
QueryKey,
type InfiniteData,
type QueryClient,
type QueryKey,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query'
@@ -100,21 +100,16 @@ export function* findAllProfilesInQueryData(
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
if (!queryData?.pages) {
continue
}
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
for (const page of queryData?.pages) {
if (page.list.creator.did === did) {
yield page.list.creator
}
for (const page of queryData?.pages) {
if (page.list.creator.did === did) {
yield page.list.creator
}
for (const item of page.items) {
if (item.subject.did === did) {
yield item.subject
}
for (const item of page.items) {
if (item.subject.did === did) {
yield item.subject
}
}
}
@@ -1,4 +1,4 @@
import {AppBskyActorDefs} from '@atproto/api'
import {type AppBskyActorDefs} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
@@ -19,7 +19,7 @@ export function useUpdateActorDeclaration({
return useMutation({
mutationFn: async (allowIncoming: 'all' | 'none' | 'following') => {
if (!currentAccount) throw new Error('Not signed in')
const result = await agent.api.com.atproto.repo.putRecord({
const result = await agent.com.atproto.repo.putRecord({
repo: currentAccount.did,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
+7 -7
View File
@@ -18,30 +18,30 @@
import {useCallback, useEffect, useMemo, useRef} from 'react'
import {
AppBskyActorDefs,
type AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
moderatePost,
} from '@atproto/api'
import {
InfiniteData,
QueryClient,
QueryKey,
type InfiniteData,
type QueryClient,
type QueryKey,
useInfiniteQuery,
useQueryClient,
} from '@tanstack/react-query'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session'
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from '../util'
import {FeedPage} from './types'
import {type FeedPage} from './types'
import {useUnreadNotificationsApi} from './unread'
import {fetchPage} from './util'
+1
View File
@@ -48,6 +48,7 @@ type OtherNotificationType =
| 'unverified'
| 'like-via-repost'
| 'repost-via-repost'
| 'subscribed-post'
| 'unknown'
type FeedNotificationBase = {
+11 -3
View File
@@ -28,6 +28,7 @@ const GROUPABLE_REASONS = [
'follow',
'like-via-repost',
'repost-via-repost',
'subscribed-post',
]
const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48
@@ -144,7 +145,8 @@ export function groupNotifications(
Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
notif.author.did !== groupedNotif.notification.author.did
(notif.author.did !== groupedNotif.notification.author.did ||
notif.reason === 'subscribed-post')
) {
const nextIsFollowBack =
notif.reason === 'follow' && notif.author.viewer?.following
@@ -252,7 +254,8 @@ function toKnownType(
notif.reason === 'verified' ||
notif.reason === 'unverified' ||
notif.reason === 'like-via-repost' ||
notif.reason === 'repost-via-repost'
notif.reason === 'repost-via-repost' ||
notif.reason === 'subscribed-post'
) {
return notif.reason as NotificationType
}
@@ -263,7 +266,12 @@ function getSubjectUri(
type: NotificationType,
notif: AppBskyNotificationListNotifications.Notification,
): string | undefined {
if (type === 'reply' || type === 'quote' || type === 'mention') {
if (
type === 'reply' ||
type === 'quote' ||
type === 'mention' ||
type === 'subscribed-post'
) {
return notif.uri
} else if (
type === 'post-like' ||
+6
View File
@@ -6,6 +6,7 @@ export enum Nux {
NeueTypography = 'NeueTypography',
ExploreInterestsCard = 'ExploreInterestsCard',
InitialVerificationAnnouncement = 'InitialVerificationAnnouncement',
ActivitySubscriptions = 'ActivitySubscriptions',
}
export const nuxNames = new Set(Object.values(Nux))
@@ -23,10 +24,15 @@ export type AppNux = BaseNux<
id: Nux.InitialVerificationAnnouncement
data: undefined
}
| {
id: Nux.ActivitySubscriptions
data: undefined
}
>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.NeueTypography]: undefined,
[Nux.ExploreInterestsCard]: undefined,
[Nux.InitialVerificationAnnouncement]: undefined,
[Nux.ActivitySubscriptions]: undefined,
}
+6
View File
@@ -24,6 +24,7 @@ import {HomeFeedAPI} from '#/lib/api/feed/home'
import {LikesFeedAPI} from '#/lib/api/feed/likes'
import {ListFeedAPI} from '#/lib/api/feed/list'
import {MergeFeedAPI} from '#/lib/api/feed/merge'
import {PostListFeedAPI} from '#/lib/api/feed/posts'
import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
@@ -53,6 +54,7 @@ export type AuthorFilter =
| 'posts_with_video'
type FeedUri = string
type ListUri = string
type PostsUriList = string
export type FeedDescriptor =
| 'following'
@@ -60,6 +62,7 @@ export type FeedDescriptor =
| `feedgen|${FeedUri}`
| `likes|${ActorDid}`
| `list|${ListUri}`
| `posts|${PostsUriList}`
| 'demo'
export interface FeedParams {
mergeFeedEnabled?: boolean
@@ -488,6 +491,9 @@ function createApi({
} else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|')
return new ListFeedAPI({agent, feedParams: {list}})
} else if (feedDesc.startsWith('posts')) {
const [_, uriList] = feedDesc.split('|')
return new PostListFeedAPI({agent, feedParams: {uris: uriList.split(',')}})
} else if (feedDesc === 'demo') {
return new DemoFeedAPI({agent})
} else {