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/cache/profile-shadow.ts13
-rw-r--r--src/state/queries/activity-subscriptions.ts130
-rw-r--r--src/state/queries/list-members.ts33
-rw-r--r--src/state/queries/messages/actor-declaration.ts4
-rw-r--r--src/state/queries/notifications/feed.ts14
-rw-r--r--src/state/queries/notifications/types.ts1
-rw-r--r--src/state/queries/notifications/util.ts14
-rw-r--r--src/state/queries/nuxs/definitions.ts6
-rw-r--r--src/state/queries/post-feed.ts6
9 files changed, 187 insertions, 34 deletions
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 0d3bb1b17..1489e65fd 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -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)
 }
diff --git a/src/state/queries/activity-subscriptions.ts b/src/state/queries/activity-subscriptions.ts
new file mode 100644
index 000000000..a81a67226
--- /dev/null
+++ b/src/state/queries/activity-subscriptions.ts
@@ -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
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
index 82c395518..152c7a5be 100644
--- a/src/state/queries/list-members.ts
+++ b/src/state/queries/list-members.ts
@@ -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
         }
       }
     }
diff --git a/src/state/queries/messages/actor-declaration.ts b/src/state/queries/messages/actor-declaration.ts
index 34fb10935..a5adb39d9 100644
--- a/src/state/queries/messages/actor-declaration.ts
+++ b/src/state/queries/messages/actor-declaration.ts
@@ -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',
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index fce7802bc..6010f11b4 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -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'
 
diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts
index e05715f77..a7b837086 100644
--- a/src/state/queries/notifications/types.ts
+++ b/src/state/queries/notifications/types.ts
@@ -48,6 +48,7 @@ type OtherNotificationType =
   | 'unverified'
   | 'like-via-repost'
   | 'repost-via-repost'
+  | 'subscribed-post'
   | 'unknown'
 
 type FeedNotificationBase = {
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 007f65cc7..faccd8087 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -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' ||
diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts
index a44ffa4c5..1947f857f 100644
--- a/src/state/queries/nuxs/definitions.ts
+++ b/src/state/queries/nuxs/definitions.ts
@@ -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,
 }
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 920892924..361081e67 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -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 {