about summary refs log tree commit diff
path: root/src/state/queries
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries')
-rw-r--r--src/state/queries/bookmarks/useBookmarkMutation.ts65
-rw-r--r--src/state/queries/bookmarks/useBookmarksQuery.ts114
-rw-r--r--src/state/queries/nuxs/definitions.ts6
3 files changed, 185 insertions, 0 deletions
diff --git a/src/state/queries/bookmarks/useBookmarkMutation.ts b/src/state/queries/bookmarks/useBookmarkMutation.ts
new file mode 100644
index 000000000..c6e745aa0
--- /dev/null
+++ b/src/state/queries/bookmarks/useBookmarkMutation.ts
@@ -0,0 +1,65 @@
+import {type AppBskyFeedDefs} from '@atproto/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {isNetworkError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {updatePostShadow} from '#/state/cache/post-shadow'
+import {
+  optimisticallyDeleteBookmark,
+  optimisticallySaveBookmark,
+} from '#/state/queries/bookmarks/useBookmarksQuery'
+import {useAgent} from '#/state/session'
+
+type MutationArgs =
+  | {action: 'create'; post: AppBskyFeedDefs.PostView}
+  | {
+      action: 'delete'
+      /**
+       * For deletions, we only need to URI. Plus, in some cases we only know the
+       * URI, such as when a post was deleted by the author.
+       */
+      uri: string
+    }
+
+export function useBookmarkMutation() {
+  const qc = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation({
+    async mutationFn(args: MutationArgs) {
+      if (args.action === 'create') {
+        updatePostShadow(qc, args.post.uri, {bookmarked: true})
+        await agent.app.bsky.bookmark.createBookmark({
+          uri: args.post.uri,
+          cid: args.post.cid,
+        })
+      } else if (args.action === 'delete') {
+        updatePostShadow(qc, args.uri, {bookmarked: false})
+        await agent.app.bsky.bookmark.deleteBookmark({
+          uri: args.uri,
+        })
+      }
+    },
+    onSuccess(_, args) {
+      if (args.action === 'create') {
+        optimisticallySaveBookmark(qc, args.post)
+      } else if (args.action === 'delete') {
+        optimisticallyDeleteBookmark(qc, {uri: args.uri})
+      }
+    },
+    onError(e, args) {
+      if (args.action === 'create') {
+        updatePostShadow(qc, args.post.uri, {bookmarked: false})
+      } else if (args.action === 'delete') {
+        updatePostShadow(qc, args.uri, {bookmarked: true})
+      }
+
+      if (!isNetworkError(e)) {
+        logger.error('bookmark mutation failed', {
+          bookmarkAction: args.action,
+          safeMessage: e,
+        })
+      }
+    },
+  })
+}
diff --git a/src/state/queries/bookmarks/useBookmarksQuery.ts b/src/state/queries/bookmarks/useBookmarksQuery.ts
new file mode 100644
index 000000000..46838facb
--- /dev/null
+++ b/src/state/queries/bookmarks/useBookmarksQuery.ts
@@ -0,0 +1,114 @@
+import {
+  type $Typed,
+  type AppBskyBookmarkGetBookmarks,
+  type AppBskyFeedDefs,
+} from '@atproto/api'
+import {
+  type InfiniteData,
+  type QueryClient,
+  type QueryKey,
+  useInfiniteQuery,
+} from '@tanstack/react-query'
+
+import {useAgent} from '#/state/session'
+
+export const bookmarksQueryKeyRoot = 'bookmarks'
+export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot]
+
+export function useBookmarksQuery() {
+  const agent = useAgent()
+
+  return useInfiniteQuery<
+    AppBskyBookmarkGetBookmarks.OutputSchema,
+    Error,
+    InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    queryKey: createBookmarksQueryKey(),
+    async queryFn({pageParam}) {
+      const res = await agent.app.bsky.bookmark.getBookmarks({
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
+
+export async function truncateAndInvalidate(qc: QueryClient) {
+  qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
+    {queryKey: [bookmarksQueryKeyRoot]},
+    data => {
+      if (data) {
+        return {
+          pageParams: data.pageParams.slice(0, 1),
+          pages: data.pages.slice(0, 1),
+        }
+      }
+      return data
+    },
+  )
+  return qc.invalidateQueries({queryKey: [bookmarksQueryKeyRoot]})
+}
+
+export async function optimisticallySaveBookmark(
+  qc: QueryClient,
+  post: AppBskyFeedDefs.PostView,
+) {
+  qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
+    {
+      queryKey: [bookmarksQueryKeyRoot],
+    },
+    data => {
+      if (!data) return data
+      return {
+        ...data,
+        pages: data.pages.map((page, index) => {
+          if (index === 0) {
+            post.$type = 'app.bsky.feed.defs#postView'
+            return {
+              ...page,
+              bookmarks: [
+                {
+                  createdAt: new Date().toISOString(),
+                  subject: {
+                    uri: post.uri,
+                    cid: post.cid,
+                  },
+                  item: post as $Typed<AppBskyFeedDefs.PostView>,
+                },
+                ...page.bookmarks,
+              ],
+            }
+          }
+          return page
+        }),
+      }
+    },
+  )
+}
+
+export async function optimisticallyDeleteBookmark(
+  qc: QueryClient,
+  {uri}: {uri: string},
+) {
+  qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>(
+    {
+      queryKey: [bookmarksQueryKeyRoot],
+    },
+    data => {
+      if (!data) return data
+      return {
+        ...data,
+        pages: data.pages.map(page => {
+          return {
+            ...page,
+            bookmarks: page.bookmarks.filter(b => b.subject.uri !== uri),
+          }
+        }),
+      }
+    },
+  )
+}
diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts
index 7577d6b20..165649447 100644
--- a/src/state/queries/nuxs/definitions.ts
+++ b/src/state/queries/nuxs/definitions.ts
@@ -9,6 +9,7 @@ export enum Nux {
   ActivitySubscriptions = 'ActivitySubscriptions',
   AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
   AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
+  BookmarksAnnouncement = 'BookmarksAnnouncement',
 
   /*
    * Blocking announcements. New IDs are required for each new announcement.
@@ -47,6 +48,10 @@ export type AppNux = BaseNux<
       id: Nux.PolicyUpdate202508
       data: undefined
     }
+  | {
+      id: Nux.BookmarksAnnouncement
+      data: undefined
+    }
 >
 
 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -57,4 +62,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
   [Nux.AgeAssuranceDismissibleNotice]: undefined,
   [Nux.AgeAssuranceDismissibleFeedBanner]: undefined,
   [Nux.PolicyUpdate202508]: undefined,
+  [Nux.BookmarksAnnouncement]: undefined,
 }