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.
Files changed (210) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +5 -0
  3. package/package.json +43 -0
  4. package/src/api/actions.ts +16 -0
  5. package/src/api/client.test.ts +3370 -0
  6. package/src/api/client.ts +4319 -0
  7. package/src/api/query-ids.json +11 -0
  8. package/src/api/query-ids.test.ts +118 -0
  9. package/src/api/query-ids.ts +59 -0
  10. package/src/api/runtime-query-ids.test.ts +926 -0
  11. package/src/api/runtime-query-ids.ts +389 -0
  12. package/src/api/types.ts +581 -0
  13. package/src/app.tsx +664 -0
  14. package/src/auth/browser-detect.ts +150 -0
  15. package/src/auth/browser-picker.ts +118 -0
  16. package/src/auth/check.test.preload.ts +94 -0
  17. package/src/auth/check.test.ts +388 -0
  18. package/src/auth/check.ts +220 -0
  19. package/src/auth/cookies.test.ts +529 -0
  20. package/src/auth/cookies.ts +299 -0
  21. package/src/auth/manual-entry.ts +88 -0
  22. package/src/auth/session.ts +30 -0
  23. package/src/components/ErrorBanner.tsx +172 -0
  24. package/src/components/Footer.tsx +90 -0
  25. package/src/components/Header.tsx +57 -0
  26. package/src/components/NotificationItem.test.ts +252 -0
  27. package/src/components/NotificationItem.tsx +80 -0
  28. package/src/components/NotificationList.test.ts +328 -0
  29. package/src/components/NotificationList.tsx +157 -0
  30. package/src/components/PostCard.tsx +186 -0
  31. package/src/components/PostList.tsx +232 -0
  32. package/src/components/QuotedPostCard.tsx +55 -0
  33. package/src/components/ReplyPreviewCard.tsx +80 -0
  34. package/src/components/ThreadView.prototype.tsx +533 -0
  35. package/src/components/Toast.tsx +28 -0
  36. package/src/config/loader.ts +69 -0
  37. package/src/config/types.ts +27 -0
  38. package/src/contexts/ModalContext.tsx +227 -0
  39. package/src/experiments/TimelineScreenExperimental.tsx +202 -0
  40. package/src/experiments/index.tsx +43 -0
  41. package/src/experiments/query-client.ts +132 -0
  42. package/src/experiments/use-bookmark-mutation.ts +342 -0
  43. package/src/experiments/use-bookmarks-query.ts +166 -0
  44. package/src/experiments/use-notifications-query.ts +368 -0
  45. package/src/experiments/use-post-detail-query.ts +187 -0
  46. package/src/experiments/use-profile-query.ts +162 -0
  47. package/src/experiments/use-timeline-query.ts +201 -0
  48. package/src/hooks/.gitkeep +0 -0
  49. package/src/hooks/useActions.ts +354 -0
  50. package/src/hooks/useBookmarkFolders.ts +70 -0
  51. package/src/hooks/useBookmarks.ts +111 -0
  52. package/src/hooks/useCountdown.ts +75 -0
  53. package/src/hooks/useListNavigation.test.ts +273 -0
  54. package/src/hooks/useListNavigation.ts +118 -0
  55. package/src/hooks/useNavigation.test.ts +340 -0
  56. package/src/hooks/useNavigation.ts +103 -0
  57. package/src/hooks/useNotifications.test.ts +377 -0
  58. package/src/hooks/useNotifications.ts +117 -0
  59. package/src/hooks/usePaginatedData.ts +217 -0
  60. package/src/hooks/usePostDetail.ts +137 -0
  61. package/src/hooks/useThread.prototype.ts +314 -0
  62. package/src/hooks/useTimeline.ts +136 -0
  63. package/src/hooks/useUserProfile.ts +142 -0
  64. package/src/index.tsx +304 -0
  65. package/src/lib/colors.ts +41 -0
  66. package/src/lib/format.ts +69 -0
  67. package/src/lib/media.ts +464 -0
  68. package/src/lib/result.ts +6 -0
  69. package/src/lib/text.tsx +76 -0
  70. package/src/modals/BookmarkFolderSelector.tsx +260 -0
  71. package/src/modals/ExitConfirmationModal.tsx +131 -0
  72. package/src/modals/FolderPicker.tsx +281 -0
  73. package/src/modals/README.md +171 -0
  74. package/src/modals/SessionExpiredModal.tsx +47 -0
  75. package/src/modals/index.ts +4 -0
  76. package/src/screens/.gitkeep +0 -0
  77. package/src/screens/BookmarksScreen.tsx +168 -0
  78. package/src/screens/NotificationsScreen.tsx +172 -0
  79. package/src/screens/PostDetailScreen.tsx +976 -0
  80. package/src/screens/ProfileScreen.tsx +528 -0
  81. package/src/screens/SplashScreen.tsx +72 -0
  82. package/src/screens/ThreadScreen.tsx +81 -0
  83. package/src/screens/TimelineScreen.tsx +188 -0
  84. package/vendor/sweet-cookie/LICENSE +22 -0
  85. package/vendor/sweet-cookie/README.md +29 -0
  86. package/vendor/sweet-cookie/dist/index.d.ts +3 -0
  87. package/vendor/sweet-cookie/dist/index.d.ts.map +1 -0
  88. package/vendor/sweet-cookie/dist/index.js +2 -0
  89. package/vendor/sweet-cookie/dist/index.js.map +1 -0
  90. package/vendor/sweet-cookie/dist/providers/chrome.d.ts +10 -0
  91. package/vendor/sweet-cookie/dist/providers/chrome.d.ts.map +1 -0
  92. package/vendor/sweet-cookie/dist/providers/chrome.js +27 -0
  93. package/vendor/sweet-cookie/dist/providers/chrome.js.map +1 -0
  94. package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts +11 -0
  95. package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map +1 -0
  96. package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js +100 -0
  97. package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map +1 -0
  98. package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts +25 -0
  99. package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map +1 -0
  100. package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js +104 -0
  101. package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map +1 -0
  102. package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts +10 -0
  103. package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map +1 -0
  104. package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js +293 -0
  105. package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js.map +1 -0
  106. package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts +10 -0
  107. package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map +1 -0
  108. package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js +26 -0
  109. package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map +1 -0
  110. package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts +7 -0
  111. package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map +1 -0
  112. package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js +51 -0
  113. package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js.map +1 -0
  114. package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts +10 -0
  115. package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map +1 -0
  116. package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js +118 -0
  117. package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js.map +1 -0
  118. package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts +7 -0
  119. package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map +1 -0
  120. package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js +38 -0
  121. package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js.map +1 -0
  122. package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts +5 -0
  123. package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map +1 -0
  124. package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js +33 -0
  125. package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js.map +1 -0
  126. package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts +24 -0
  127. package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map +1 -0
  128. package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js +30 -0
  129. package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js.map +1 -0
  130. package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts +11 -0
  131. package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts.map +1 -0
  132. package/vendor/sweet-cookie/dist/providers/chromium/paths.js +43 -0
  133. package/vendor/sweet-cookie/dist/providers/chromium/paths.js.map +1 -0
  134. package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts +8 -0
  135. package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map +1 -0
  136. package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js +41 -0
  137. package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map +1 -0
  138. package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts +8 -0
  139. package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map +1 -0
  140. package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js +53 -0
  141. package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js.map +1 -0
  142. package/vendor/sweet-cookie/dist/providers/edge.d.ts +8 -0
  143. package/vendor/sweet-cookie/dist/providers/edge.d.ts.map +1 -0
  144. package/vendor/sweet-cookie/dist/providers/edge.js +27 -0
  145. package/vendor/sweet-cookie/dist/providers/edge.js.map +1 -0
  146. package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts +7 -0
  147. package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map +1 -0
  148. package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js +53 -0
  149. package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js.map +1 -0
  150. package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts +8 -0
  151. package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map +1 -0
  152. package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js +60 -0
  153. package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js.map +1 -0
  154. package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts +7 -0
  155. package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map +1 -0
  156. package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js +38 -0
  157. package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js.map +1 -0
  158. package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts +6 -0
  159. package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map +1 -0
  160. package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js +257 -0
  161. package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js.map +1 -0
  162. package/vendor/sweet-cookie/dist/providers/inline.d.ts +8 -0
  163. package/vendor/sweet-cookie/dist/providers/inline.d.ts.map +1 -0
  164. package/vendor/sweet-cookie/dist/providers/inline.js +71 -0
  165. package/vendor/sweet-cookie/dist/providers/inline.js.map +1 -0
  166. package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts +6 -0
  167. package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map +1 -0
  168. package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js +173 -0
  169. package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js.map +1 -0
  170. package/vendor/sweet-cookie/dist/public.d.ts +26 -0
  171. package/vendor/sweet-cookie/dist/public.d.ts.map +1 -0
  172. package/vendor/sweet-cookie/dist/public.js +197 -0
  173. package/vendor/sweet-cookie/dist/public.js.map +1 -0
  174. package/vendor/sweet-cookie/dist/types.d.ts +127 -0
  175. package/vendor/sweet-cookie/dist/types.d.ts.map +1 -0
  176. package/vendor/sweet-cookie/dist/types.js +2 -0
  177. package/vendor/sweet-cookie/dist/types.js.map +1 -0
  178. package/vendor/sweet-cookie/dist/util/base64.d.ts +2 -0
  179. package/vendor/sweet-cookie/dist/util/base64.d.ts.map +1 -0
  180. package/vendor/sweet-cookie/dist/util/base64.js +18 -0
  181. package/vendor/sweet-cookie/dist/util/base64.js.map +1 -0
  182. package/vendor/sweet-cookie/dist/util/exec.d.ts +8 -0
  183. package/vendor/sweet-cookie/dist/util/exec.d.ts.map +1 -0
  184. package/vendor/sweet-cookie/dist/util/exec.js +110 -0
  185. package/vendor/sweet-cookie/dist/util/exec.js.map +1 -0
  186. package/vendor/sweet-cookie/dist/util/expire.d.ts +2 -0
  187. package/vendor/sweet-cookie/dist/util/expire.d.ts.map +1 -0
  188. package/vendor/sweet-cookie/dist/util/expire.js +32 -0
  189. package/vendor/sweet-cookie/dist/util/expire.js.map +1 -0
  190. package/vendor/sweet-cookie/dist/util/fs.d.ts +2 -0
  191. package/vendor/sweet-cookie/dist/util/fs.d.ts.map +1 -0
  192. package/vendor/sweet-cookie/dist/util/fs.js +13 -0
  193. package/vendor/sweet-cookie/dist/util/fs.js.map +1 -0
  194. package/vendor/sweet-cookie/dist/util/hostMatch.d.ts +2 -0
  195. package/vendor/sweet-cookie/dist/util/hostMatch.d.ts.map +1 -0
  196. package/vendor/sweet-cookie/dist/util/hostMatch.js +7 -0
  197. package/vendor/sweet-cookie/dist/util/hostMatch.js.map +1 -0
  198. package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts +5 -0
  199. package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts.map +1 -0
  200. package/vendor/sweet-cookie/dist/util/nodeSqlite.js +58 -0
  201. package/vendor/sweet-cookie/dist/util/nodeSqlite.js.map +1 -0
  202. package/vendor/sweet-cookie/dist/util/origins.d.ts +2 -0
  203. package/vendor/sweet-cookie/dist/util/origins.d.ts.map +1 -0
  204. package/vendor/sweet-cookie/dist/util/origins.js +27 -0
  205. package/vendor/sweet-cookie/dist/util/origins.js.map +1 -0
  206. package/vendor/sweet-cookie/dist/util/runtime.d.ts +2 -0
  207. package/vendor/sweet-cookie/dist/util/runtime.d.ts.map +1 -0
  208. package/vendor/sweet-cookie/dist/util/runtime.js +8 -0
  209. package/vendor/sweet-cookie/dist/util/runtime.js.map +1 -0
  210. 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
+ }