📓 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>
This commit is contained in:
Eric Bailey
2025-09-04 17:30:15 -05:00
committed by GitHub
parent 04b869714e
commit 535d4d6cf7
38 changed files with 1251 additions and 163 deletions
@@ -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,
})
}
},
})
}
@@ -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),
}
}),
}
},
)
}
+6
View File
@@ -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,
}