xfeed 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +5 -0
- package/package.json +43 -0
- package/src/api/actions.ts +16 -0
- package/src/api/client.test.ts +3370 -0
- package/src/api/client.ts +4319 -0
- package/src/api/query-ids.json +11 -0
- package/src/api/query-ids.test.ts +118 -0
- package/src/api/query-ids.ts +59 -0
- package/src/api/runtime-query-ids.test.ts +926 -0
- package/src/api/runtime-query-ids.ts +389 -0
- package/src/api/types.ts +581 -0
- package/src/app.tsx +664 -0
- package/src/auth/browser-detect.ts +150 -0
- package/src/auth/browser-picker.ts +118 -0
- package/src/auth/check.test.preload.ts +94 -0
- package/src/auth/check.test.ts +388 -0
- package/src/auth/check.ts +220 -0
- package/src/auth/cookies.test.ts +529 -0
- package/src/auth/cookies.ts +299 -0
- package/src/auth/manual-entry.ts +88 -0
- package/src/auth/session.ts +30 -0
- package/src/components/ErrorBanner.tsx +172 -0
- package/src/components/Footer.tsx +90 -0
- package/src/components/Header.tsx +57 -0
- package/src/components/NotificationItem.test.ts +252 -0
- package/src/components/NotificationItem.tsx +80 -0
- package/src/components/NotificationList.test.ts +328 -0
- package/src/components/NotificationList.tsx +157 -0
- package/src/components/PostCard.tsx +186 -0
- package/src/components/PostList.tsx +232 -0
- package/src/components/QuotedPostCard.tsx +55 -0
- package/src/components/ReplyPreviewCard.tsx +80 -0
- package/src/components/ThreadView.prototype.tsx +533 -0
- package/src/components/Toast.tsx +28 -0
- package/src/config/loader.ts +69 -0
- package/src/config/types.ts +27 -0
- package/src/contexts/ModalContext.tsx +227 -0
- package/src/experiments/TimelineScreenExperimental.tsx +202 -0
- package/src/experiments/index.tsx +43 -0
- package/src/experiments/query-client.ts +132 -0
- package/src/experiments/use-bookmark-mutation.ts +342 -0
- package/src/experiments/use-bookmarks-query.ts +166 -0
- package/src/experiments/use-notifications-query.ts +368 -0
- package/src/experiments/use-post-detail-query.ts +187 -0
- package/src/experiments/use-profile-query.ts +162 -0
- package/src/experiments/use-timeline-query.ts +201 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/useActions.ts +354 -0
- package/src/hooks/useBookmarkFolders.ts +70 -0
- package/src/hooks/useBookmarks.ts +111 -0
- package/src/hooks/useCountdown.ts +75 -0
- package/src/hooks/useListNavigation.test.ts +273 -0
- package/src/hooks/useListNavigation.ts +118 -0
- package/src/hooks/useNavigation.test.ts +340 -0
- package/src/hooks/useNavigation.ts +103 -0
- package/src/hooks/useNotifications.test.ts +377 -0
- package/src/hooks/useNotifications.ts +117 -0
- package/src/hooks/usePaginatedData.ts +217 -0
- package/src/hooks/usePostDetail.ts +137 -0
- package/src/hooks/useThread.prototype.ts +314 -0
- package/src/hooks/useTimeline.ts +136 -0
- package/src/hooks/useUserProfile.ts +142 -0
- package/src/index.tsx +304 -0
- package/src/lib/colors.ts +41 -0
- package/src/lib/format.ts +69 -0
- package/src/lib/media.ts +464 -0
- package/src/lib/result.ts +6 -0
- package/src/lib/text.tsx +76 -0
- package/src/modals/BookmarkFolderSelector.tsx +260 -0
- package/src/modals/ExitConfirmationModal.tsx +131 -0
- package/src/modals/FolderPicker.tsx +281 -0
- package/src/modals/README.md +171 -0
- package/src/modals/SessionExpiredModal.tsx +47 -0
- package/src/modals/index.ts +4 -0
- package/src/screens/.gitkeep +0 -0
- package/src/screens/BookmarksScreen.tsx +168 -0
- package/src/screens/NotificationsScreen.tsx +172 -0
- package/src/screens/PostDetailScreen.tsx +976 -0
- package/src/screens/ProfileScreen.tsx +528 -0
- package/src/screens/SplashScreen.tsx +72 -0
- package/src/screens/ThreadScreen.tsx +81 -0
- package/src/screens/TimelineScreen.tsx +188 -0
- package/vendor/sweet-cookie/LICENSE +22 -0
- package/vendor/sweet-cookie/README.md +29 -0
- package/vendor/sweet-cookie/dist/index.d.ts +3 -0
- package/vendor/sweet-cookie/dist/index.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/index.js +2 -0
- package/vendor/sweet-cookie/dist/index.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chrome.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chrome.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chrome.js +27 -0
- package/vendor/sweet-cookie/dist/providers/chrome.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts +11 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js +100 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts +25 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js +104 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js +293 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js +26 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js +51 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js +118 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js +38 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts +5 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js +33 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts +24 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js +30 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts +11 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.js +43 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js +41 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js +53 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edge.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/edge.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edge.js +27 -0
- package/vendor/sweet-cookie/dist/providers/edge.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js +53 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js +60 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js +38 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts +6 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js +257 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/inline.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/inline.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/inline.js +71 -0
- package/vendor/sweet-cookie/dist/providers/inline.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts +6 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js +173 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js.map +1 -0
- package/vendor/sweet-cookie/dist/public.d.ts +26 -0
- package/vendor/sweet-cookie/dist/public.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/public.js +197 -0
- package/vendor/sweet-cookie/dist/public.js.map +1 -0
- package/vendor/sweet-cookie/dist/types.d.ts +127 -0
- package/vendor/sweet-cookie/dist/types.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/types.js +2 -0
- package/vendor/sweet-cookie/dist/types.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/base64.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/base64.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/base64.js +18 -0
- package/vendor/sweet-cookie/dist/util/base64.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/exec.d.ts +8 -0
- package/vendor/sweet-cookie/dist/util/exec.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/exec.js +110 -0
- package/vendor/sweet-cookie/dist/util/exec.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/expire.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/expire.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/expire.js +32 -0
- package/vendor/sweet-cookie/dist/util/expire.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/fs.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/fs.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/fs.js +13 -0
- package/vendor/sweet-cookie/dist/util/fs.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.js +7 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts +5 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.js +58 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/origins.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/origins.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/origins.js +27 -0
- package/vendor/sweet-cookie/dist/util/origins.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/runtime.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/runtime.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/runtime.js +8 -0
- package/vendor/sweet-cookie/dist/util/runtime.js.map +1 -0
- package/vendor/sweet-cookie/package.json +40 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useBookmarkMutation - TanStack Query mutation hook for bookmark operations
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Optimistic updates for immediate UI feedback
|
|
6
|
+
* - Automatic cache rollback on error
|
|
7
|
+
* - Supports both adding and removing bookmarks
|
|
8
|
+
* - Updates all relevant bookmark caches (all bookmarks + folder-specific)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
12
|
+
import { useCallback, useRef, useState } from "react";
|
|
13
|
+
|
|
14
|
+
import type { XClient } from "@/api/client";
|
|
15
|
+
import type { TweetData } from "@/api/types";
|
|
16
|
+
|
|
17
|
+
import { queryKeys } from "./query-client";
|
|
18
|
+
|
|
19
|
+
interface BookmarksPage {
|
|
20
|
+
tweets: TweetData[];
|
|
21
|
+
nextCursor?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface BookmarksCacheData {
|
|
25
|
+
pages: BookmarksPage[];
|
|
26
|
+
pageParams: (string | undefined)[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface UseBookmarkMutationOptions {
|
|
30
|
+
client: XClient;
|
|
31
|
+
/** Callback when mutation succeeds */
|
|
32
|
+
onSuccess?: (message: string) => void;
|
|
33
|
+
/** Callback when mutation fails */
|
|
34
|
+
onError?: (error: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface MutationContext {
|
|
38
|
+
previousAllBookmarks?: BookmarksCacheData;
|
|
39
|
+
previousFolderBookmarks?: Map<string, BookmarksCacheData>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Duration for "just acted" visual feedback in ms */
|
|
43
|
+
const JUST_ACTED_DURATION = 600;
|
|
44
|
+
|
|
45
|
+
export function useBookmarkMutation({
|
|
46
|
+
client,
|
|
47
|
+
onSuccess,
|
|
48
|
+
onError,
|
|
49
|
+
}: UseBookmarkMutationOptions) {
|
|
50
|
+
const queryClient = useQueryClient();
|
|
51
|
+
|
|
52
|
+
// Track pending state per tweet
|
|
53
|
+
const [pendingTweets, setPendingTweets] = useState<Set<string>>(new Set());
|
|
54
|
+
|
|
55
|
+
// Track "just bookmarked" state per tweet
|
|
56
|
+
const [justBookmarkedTweets, setJustBookmarkedTweets] = useState<Set<string>>(
|
|
57
|
+
new Set()
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Timeouts for clearing justBookmarked state
|
|
61
|
+
const justBookmarkedTimeouts = useRef<
|
|
62
|
+
Map<string, ReturnType<typeof setTimeout>>
|
|
63
|
+
>(new Map());
|
|
64
|
+
|
|
65
|
+
// Add bookmark mutation
|
|
66
|
+
const addMutation = useMutation({
|
|
67
|
+
mutationFn: async (tweet: TweetData) => {
|
|
68
|
+
const result = await client.createBookmark(tweet.id);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
throw new Error(result.error);
|
|
71
|
+
}
|
|
72
|
+
return { tweet, success: true };
|
|
73
|
+
},
|
|
74
|
+
onMutate: async (tweet) => {
|
|
75
|
+
// Mark as pending
|
|
76
|
+
setPendingTweets((prev) => new Set(prev).add(tweet.id));
|
|
77
|
+
|
|
78
|
+
// Cancel any outgoing refetches
|
|
79
|
+
await queryClient.cancelQueries({
|
|
80
|
+
queryKey: queryKeys.bookmarks.all,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Snapshot the previous values
|
|
84
|
+
const previousAllBookmarks = queryClient.getQueryData<BookmarksCacheData>(
|
|
85
|
+
queryKeys.bookmarks.list(undefined)
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Optimistically add to "all bookmarks" cache
|
|
89
|
+
if (previousAllBookmarks) {
|
|
90
|
+
queryClient.setQueryData<BookmarksCacheData>(
|
|
91
|
+
queryKeys.bookmarks.list(undefined),
|
|
92
|
+
(old) => {
|
|
93
|
+
if (!old?.pages?.length) return old;
|
|
94
|
+
|
|
95
|
+
// Add tweet to the beginning of the first page
|
|
96
|
+
const firstPage = old.pages[0];
|
|
97
|
+
if (!firstPage) return old;
|
|
98
|
+
|
|
99
|
+
// Check if already exists
|
|
100
|
+
const exists = old.pages.some((page) =>
|
|
101
|
+
page.tweets.some((t) => t.id === tweet.id)
|
|
102
|
+
);
|
|
103
|
+
if (exists) return old;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
...old,
|
|
107
|
+
pages: [
|
|
108
|
+
{
|
|
109
|
+
...firstPage,
|
|
110
|
+
tweets: [tweet, ...firstPage.tweets],
|
|
111
|
+
},
|
|
112
|
+
...old.pages.slice(1),
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { previousAllBookmarks } as MutationContext;
|
|
120
|
+
},
|
|
121
|
+
onError: (error, tweet, context) => {
|
|
122
|
+
// Remove from pending
|
|
123
|
+
setPendingTweets((prev) => {
|
|
124
|
+
const next = new Set(prev);
|
|
125
|
+
next.delete(tweet.id);
|
|
126
|
+
return next;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Rollback cache
|
|
130
|
+
if (context?.previousAllBookmarks) {
|
|
131
|
+
queryClient.setQueryData(
|
|
132
|
+
queryKeys.bookmarks.list(undefined),
|
|
133
|
+
context.previousAllBookmarks
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const errorMessage =
|
|
138
|
+
error instanceof Error ? error.message : String(error);
|
|
139
|
+
|
|
140
|
+
// Handle "already bookmarked" gracefully
|
|
141
|
+
if (errorMessage.toLowerCase().includes("already bookmarked")) {
|
|
142
|
+
onSuccess?.("Already bookmarked");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
onError?.(errorMessage);
|
|
147
|
+
},
|
|
148
|
+
onSuccess: (_, tweet) => {
|
|
149
|
+
// Remove from pending
|
|
150
|
+
setPendingTweets((prev) => {
|
|
151
|
+
const next = new Set(prev);
|
|
152
|
+
next.delete(tweet.id);
|
|
153
|
+
return next;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Set justBookmarked for visual feedback
|
|
157
|
+
const existingTimeout = justBookmarkedTimeouts.current.get(tweet.id);
|
|
158
|
+
if (existingTimeout) clearTimeout(existingTimeout);
|
|
159
|
+
|
|
160
|
+
setJustBookmarkedTweets((prev) => new Set(prev).add(tweet.id));
|
|
161
|
+
|
|
162
|
+
const timeout = setTimeout(() => {
|
|
163
|
+
setJustBookmarkedTweets((prev) => {
|
|
164
|
+
const next = new Set(prev);
|
|
165
|
+
next.delete(tweet.id);
|
|
166
|
+
return next;
|
|
167
|
+
});
|
|
168
|
+
justBookmarkedTimeouts.current.delete(tweet.id);
|
|
169
|
+
}, JUST_ACTED_DURATION);
|
|
170
|
+
justBookmarkedTimeouts.current.set(tweet.id, timeout);
|
|
171
|
+
|
|
172
|
+
onSuccess?.("Bookmarked");
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Remove bookmark mutation
|
|
177
|
+
const removeMutation = useMutation({
|
|
178
|
+
mutationFn: async ({
|
|
179
|
+
tweetId,
|
|
180
|
+
folderId,
|
|
181
|
+
}: {
|
|
182
|
+
tweetId: string;
|
|
183
|
+
tweet?: TweetData;
|
|
184
|
+
folderId?: string;
|
|
185
|
+
}) => {
|
|
186
|
+
const result = await client.deleteBookmark(tweetId);
|
|
187
|
+
if (!result.success) {
|
|
188
|
+
throw new Error(result.error);
|
|
189
|
+
}
|
|
190
|
+
return { tweetId, folderId, success: true };
|
|
191
|
+
},
|
|
192
|
+
onMutate: async ({ tweetId, folderId }) => {
|
|
193
|
+
// Mark as pending
|
|
194
|
+
setPendingTweets((prev) => new Set(prev).add(tweetId));
|
|
195
|
+
|
|
196
|
+
// Cancel any outgoing refetches
|
|
197
|
+
await queryClient.cancelQueries({
|
|
198
|
+
queryKey: queryKeys.bookmarks.all,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Snapshot and update "all bookmarks" cache
|
|
202
|
+
const previousAllBookmarks = queryClient.getQueryData<BookmarksCacheData>(
|
|
203
|
+
queryKeys.bookmarks.list(undefined)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (previousAllBookmarks) {
|
|
207
|
+
queryClient.setQueryData<BookmarksCacheData>(
|
|
208
|
+
queryKeys.bookmarks.list(undefined),
|
|
209
|
+
(old) => {
|
|
210
|
+
if (!old) return old;
|
|
211
|
+
return {
|
|
212
|
+
...old,
|
|
213
|
+
pages: old.pages.map((page) => ({
|
|
214
|
+
...page,
|
|
215
|
+
tweets: page.tweets.filter((t) => t.id !== tweetId),
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Snapshot and update folder-specific cache if provided
|
|
223
|
+
const previousFolderBookmarks = new Map<string, BookmarksCacheData>();
|
|
224
|
+
if (folderId) {
|
|
225
|
+
const folderData = queryClient.getQueryData<BookmarksCacheData>(
|
|
226
|
+
queryKeys.bookmarks.list(folderId)
|
|
227
|
+
);
|
|
228
|
+
if (folderData) {
|
|
229
|
+
previousFolderBookmarks.set(folderId, folderData);
|
|
230
|
+
queryClient.setQueryData<BookmarksCacheData>(
|
|
231
|
+
queryKeys.bookmarks.list(folderId),
|
|
232
|
+
(old) => {
|
|
233
|
+
if (!old) return old;
|
|
234
|
+
return {
|
|
235
|
+
...old,
|
|
236
|
+
pages: old.pages.map((page) => ({
|
|
237
|
+
...page,
|
|
238
|
+
tweets: page.tweets.filter((t) => t.id !== tweetId),
|
|
239
|
+
})),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
previousAllBookmarks,
|
|
248
|
+
previousFolderBookmarks,
|
|
249
|
+
} as MutationContext;
|
|
250
|
+
},
|
|
251
|
+
onError: (error, { tweetId }, context) => {
|
|
252
|
+
// Remove from pending
|
|
253
|
+
setPendingTweets((prev) => {
|
|
254
|
+
const next = new Set(prev);
|
|
255
|
+
next.delete(tweetId);
|
|
256
|
+
return next;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Rollback caches
|
|
260
|
+
if (context?.previousAllBookmarks) {
|
|
261
|
+
queryClient.setQueryData(
|
|
262
|
+
queryKeys.bookmarks.list(undefined),
|
|
263
|
+
context.previousAllBookmarks
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (context?.previousFolderBookmarks) {
|
|
267
|
+
for (const [fId, data] of context.previousFolderBookmarks) {
|
|
268
|
+
queryClient.setQueryData(queryKeys.bookmarks.list(fId), data);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const errorMessage =
|
|
273
|
+
error instanceof Error ? error.message : String(error);
|
|
274
|
+
|
|
275
|
+
// Handle "not found" gracefully (already removed)
|
|
276
|
+
if (errorMessage.toLowerCase().includes("not found")) {
|
|
277
|
+
onSuccess?.("Already removed");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
onError?.(errorMessage);
|
|
282
|
+
},
|
|
283
|
+
onSuccess: (_, { tweetId }) => {
|
|
284
|
+
// Remove from pending
|
|
285
|
+
setPendingTweets((prev) => {
|
|
286
|
+
const next = new Set(prev);
|
|
287
|
+
next.delete(tweetId);
|
|
288
|
+
return next;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
onSuccess?.("Removed bookmark");
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Public API
|
|
296
|
+
const addBookmark = useCallback(
|
|
297
|
+
(tweet: TweetData) => {
|
|
298
|
+
if (pendingTweets.has(tweet.id)) return;
|
|
299
|
+
addMutation.mutate(tweet);
|
|
300
|
+
},
|
|
301
|
+
[addMutation, pendingTweets]
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const removeBookmark = useCallback(
|
|
305
|
+
(tweetId: string, folderId?: string, tweet?: TweetData) => {
|
|
306
|
+
if (pendingTweets.has(tweetId)) return;
|
|
307
|
+
removeMutation.mutate({ tweetId, folderId, tweet });
|
|
308
|
+
},
|
|
309
|
+
[removeMutation, pendingTweets]
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const toggleBookmark = useCallback(
|
|
313
|
+
(tweet: TweetData, isCurrentlyBookmarked: boolean, folderId?: string) => {
|
|
314
|
+
if (isCurrentlyBookmarked) {
|
|
315
|
+
removeBookmark(tweet.id, folderId, tweet);
|
|
316
|
+
} else {
|
|
317
|
+
addBookmark(tweet);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
[addBookmark, removeBookmark]
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const isPending = useCallback(
|
|
324
|
+
(tweetId: string) => pendingTweets.has(tweetId),
|
|
325
|
+
[pendingTweets]
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const isJustBookmarked = useCallback(
|
|
329
|
+
(tweetId: string) => justBookmarkedTweets.has(tweetId),
|
|
330
|
+
[justBookmarkedTweets]
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
addBookmark,
|
|
335
|
+
removeBookmark,
|
|
336
|
+
toggleBookmark,
|
|
337
|
+
isPending,
|
|
338
|
+
isJustBookmarked,
|
|
339
|
+
isAddPending: addMutation.isPending,
|
|
340
|
+
isRemovePending: removeMutation.isPending,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useBookmarksQuery - TanStack Query hook for bookmarks fetching
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Infinite query for cursor-based pagination
|
|
6
|
+
* - Folder filtering with separate cache keys per folder
|
|
7
|
+
* - Deduplication across pages
|
|
8
|
+
* - Cache mutation for bookmark removal
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
|
12
|
+
import { useCallback, useMemo } from "react";
|
|
13
|
+
|
|
14
|
+
import type { XClient } from "@/api/client";
|
|
15
|
+
import type { ApiError, TweetData } from "@/api/types";
|
|
16
|
+
|
|
17
|
+
import { queryKeys } from "./query-client";
|
|
18
|
+
|
|
19
|
+
interface BookmarksPage {
|
|
20
|
+
tweets: TweetData[];
|
|
21
|
+
nextCursor?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface UseBookmarksQueryOptions {
|
|
25
|
+
client: XClient;
|
|
26
|
+
/** Optional folder ID to filter bookmarks by a specific folder */
|
|
27
|
+
folderId?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface UseBookmarksQueryResult {
|
|
31
|
+
/** List of bookmarked posts */
|
|
32
|
+
posts: TweetData[];
|
|
33
|
+
/** Whether initial data is loading */
|
|
34
|
+
isLoading: boolean;
|
|
35
|
+
/** Whether more data is being loaded (pagination) */
|
|
36
|
+
isFetchingNextPage: boolean;
|
|
37
|
+
/** Whether there are more posts to load */
|
|
38
|
+
hasNextPage: boolean;
|
|
39
|
+
/** Error if fetch failed */
|
|
40
|
+
error: ApiError | null;
|
|
41
|
+
/** Fetch next page of results */
|
|
42
|
+
fetchNextPage: () => void;
|
|
43
|
+
/** Manually refresh bookmarks */
|
|
44
|
+
refresh: () => void;
|
|
45
|
+
/** Whether refresh is in progress */
|
|
46
|
+
isRefetching: boolean;
|
|
47
|
+
/** Remove a post from the cache (used when unbookmarked) */
|
|
48
|
+
removePost: (tweetId: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch bookmarks page from X API
|
|
53
|
+
*/
|
|
54
|
+
async function fetchBookmarksPage(
|
|
55
|
+
client: XClient,
|
|
56
|
+
folderId: string | undefined | null,
|
|
57
|
+
cursor?: string
|
|
58
|
+
): Promise<BookmarksPage> {
|
|
59
|
+
const result = folderId
|
|
60
|
+
? await client.getBookmarkFolderTimelineV2(folderId, 30, cursor)
|
|
61
|
+
: await client.getBookmarksV2(30, cursor);
|
|
62
|
+
|
|
63
|
+
if (!result.success) {
|
|
64
|
+
throw result.error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
tweets: result.tweets,
|
|
69
|
+
nextCursor: result.nextCursor,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Deduplicate tweets across all pages
|
|
75
|
+
*/
|
|
76
|
+
function deduplicateTweets(pages: BookmarksPage[]): TweetData[] {
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
const result: TweetData[] = [];
|
|
79
|
+
|
|
80
|
+
for (const page of pages) {
|
|
81
|
+
for (const tweet of page.tweets) {
|
|
82
|
+
if (!seen.has(tweet.id)) {
|
|
83
|
+
seen.add(tweet.id);
|
|
84
|
+
result.push(tweet);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useBookmarksQuery({
|
|
93
|
+
client,
|
|
94
|
+
folderId,
|
|
95
|
+
}: UseBookmarksQueryOptions): UseBookmarksQueryResult {
|
|
96
|
+
const queryClient = useQueryClient();
|
|
97
|
+
|
|
98
|
+
// Normalize folderId: treat null and undefined the same
|
|
99
|
+
const normalizedFolderId = folderId ?? undefined;
|
|
100
|
+
|
|
101
|
+
// Main infinite query for bookmarks
|
|
102
|
+
const {
|
|
103
|
+
data,
|
|
104
|
+
isLoading,
|
|
105
|
+
isFetchingNextPage,
|
|
106
|
+
hasNextPage,
|
|
107
|
+
error,
|
|
108
|
+
fetchNextPage,
|
|
109
|
+
refetch,
|
|
110
|
+
isRefetching,
|
|
111
|
+
} = useInfiniteQuery({
|
|
112
|
+
queryKey: queryKeys.bookmarks.list(normalizedFolderId),
|
|
113
|
+
queryFn: async ({ pageParam }) => {
|
|
114
|
+
return fetchBookmarksPage(client, normalizedFolderId, pageParam);
|
|
115
|
+
},
|
|
116
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
117
|
+
initialPageParam: undefined as string | undefined,
|
|
118
|
+
staleTime: 0,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Flatten and deduplicate tweets from all pages
|
|
122
|
+
const posts = useMemo(() => {
|
|
123
|
+
if (!data?.pages) return [];
|
|
124
|
+
return deduplicateTweets(data.pages);
|
|
125
|
+
}, [data?.pages]);
|
|
126
|
+
|
|
127
|
+
// Manual refresh
|
|
128
|
+
const refresh = useCallback(() => {
|
|
129
|
+
refetch();
|
|
130
|
+
}, [refetch]);
|
|
131
|
+
|
|
132
|
+
// Remove a post from the cache (called when user unbookmarks)
|
|
133
|
+
const removePost = useCallback(
|
|
134
|
+
(tweetId: string) => {
|
|
135
|
+
const queryKey = queryKeys.bookmarks.list(normalizedFolderId);
|
|
136
|
+
|
|
137
|
+
queryClient.setQueryData<{
|
|
138
|
+
pages: BookmarksPage[];
|
|
139
|
+
pageParams: (string | undefined)[];
|
|
140
|
+
}>(queryKey, (oldData) => {
|
|
141
|
+
if (!oldData) return oldData;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
...oldData,
|
|
145
|
+
pages: oldData.pages.map((page) => ({
|
|
146
|
+
...page,
|
|
147
|
+
tweets: page.tweets.filter((tweet) => tweet.id !== tweetId),
|
|
148
|
+
})),
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
[queryClient, normalizedFolderId]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
posts,
|
|
157
|
+
isLoading,
|
|
158
|
+
isFetchingNextPage,
|
|
159
|
+
hasNextPage: hasNextPage ?? false,
|
|
160
|
+
error: error as ApiError | null,
|
|
161
|
+
fetchNextPage,
|
|
162
|
+
refresh,
|
|
163
|
+
isRefetching,
|
|
164
|
+
removePost,
|
|
165
|
+
};
|
|
166
|
+
}
|