about summary refs log tree commit diff
path: root/src/state/queries/bookmarks
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-09-04 17:30:15 -0500
committerGitHub <noreply@github.com>2025-09-04 17:30:15 -0500
commit535d4d6cf74cfb49a70804bccb4de1613d2ac09c (patch)
tree78198de5712398e5a9a4b43ec69b254f81081442 /src/state/queries/bookmarks
parent04b869714e512ed29653892d45dab806396824e1 (diff)
downloadvoidsky-535d4d6cf74cfb49a70804bccb4de1613d2ac09c.tar.zst
📓 Bookmarks (#8976)
* Add button to controls, respace

* Hook up shadow and mutation

* Add Bookmarks screen

* Build out Bookmarks screen

* Handle removals via shadow

* Use truncateAndInvalidate strategy

* Add empty state

* Add toasts

* Add undo buttons to toasts

* Stage NUX, needs image

* Finesse post controls

* New reply icon

* Use curvier variant of repost icon

* Prevent layout shift with align_start

* Update api pkg

* Swap in new image

* Limit spacing on desktop

* Rm decimals over 10k

* Better optimistic adding/removing

* Add metrics

* Comment

* Remove unused code block

* Remove debug limit

* Fork shadow for web/native

* Tweak alt

* add preventExpansion: true

* Refine hitslop

* Add count to anchor

* Reduce space in compact mode

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/state/queries/bookmarks')
-rw-r--r--src/state/queries/bookmarks/useBookmarkMutation.ts65
-rw-r--r--src/state/queries/bookmarks/useBookmarksQuery.ts114
2 files changed, 179 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),
+          }
+        }),
+      }
+    },
+  )
+}