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,201 @@
1
+ /**
2
+ * useTimelineQuery - TanStack Query hook for timeline fetching
3
+ *
4
+ * Features:
5
+ * - Infinite query for cursor-based pagination
6
+ * - Shows "Refresh for new posts" banner after 5 minutes
7
+ * - Deduplication across pages
8
+ */
9
+
10
+ import { useInfiniteQuery } from "@tanstack/react-query";
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
12
+
13
+ import type { XClient } from "@/api/client";
14
+ import type { ApiError, TweetData } from "@/api/types";
15
+
16
+ import { queryKeys } from "./query-client";
17
+
18
+ export type TimelineTab = "for_you" | "following";
19
+
20
+ interface TimelinePage {
21
+ tweets: TweetData[];
22
+ nextCursor?: string;
23
+ }
24
+
25
+ interface UseTimelineQueryOptions {
26
+ client: XClient;
27
+ initialTab?: TimelineTab;
28
+ /** Time in ms before showing refresh banner (default: 300000 = 5 minutes) */
29
+ refreshBannerDelay?: number;
30
+ }
31
+
32
+ interface UseTimelineQueryResult {
33
+ /** Currently active tab */
34
+ tab: TimelineTab;
35
+ /** Switch to a different tab */
36
+ setTab: (tab: TimelineTab) => void;
37
+ /** Flattened list of posts with deduplication */
38
+ posts: TweetData[];
39
+ /** Whether initial data is loading */
40
+ isLoading: boolean;
41
+ /** Whether more data is being loaded (pagination) */
42
+ isFetchingNextPage: boolean;
43
+ /** Whether there are more posts to load */
44
+ hasNextPage: boolean;
45
+ /** Error if fetch failed */
46
+ error: ApiError | null;
47
+ /** Fetch next page of results */
48
+ fetchNextPage: () => void;
49
+ /** Manually refresh timeline */
50
+ refresh: () => void;
51
+ /** Whether to show the refresh banner */
52
+ showRefreshBanner: boolean;
53
+ /** Whether refresh is in progress */
54
+ isRefetching: boolean;
55
+ }
56
+
57
+ /**
58
+ * Fetch timeline page from X API
59
+ */
60
+ async function fetchTimelinePage(
61
+ client: XClient,
62
+ tab: TimelineTab,
63
+ cursor?: string
64
+ ): Promise<TimelinePage> {
65
+ // Use V2 for initial fetch (returns ApiError), V1 for pagination
66
+ const result = cursor
67
+ ? tab === "for_you"
68
+ ? await client.getHomeTimeline(30, cursor)
69
+ : await client.getHomeLatestTimeline(30, cursor)
70
+ : tab === "for_you"
71
+ ? await client.getHomeTimelineV2(30)
72
+ : await client.getHomeLatestTimelineV2(30);
73
+
74
+ if (!result.success) {
75
+ // Normalize V1 string errors to ApiError
76
+ const error: ApiError =
77
+ typeof result.error === "string"
78
+ ? { type: "unknown", message: result.error }
79
+ : result.error;
80
+ throw error;
81
+ }
82
+
83
+ return {
84
+ tweets: result.tweets,
85
+ nextCursor: result.nextCursor,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Deduplicate tweets across all pages
91
+ */
92
+ function deduplicateTweets(pages: TimelinePage[]): TweetData[] {
93
+ const seen = new Set<string>();
94
+ const result: TweetData[] = [];
95
+
96
+ for (const page of pages) {
97
+ for (const tweet of page.tweets) {
98
+ if (!seen.has(tweet.id)) {
99
+ seen.add(tweet.id);
100
+ result.push(tweet);
101
+ }
102
+ }
103
+ }
104
+
105
+ return result;
106
+ }
107
+
108
+ const DEFAULT_REFRESH_BANNER_DELAY = 5 * 60 * 1000; // 5 minutes
109
+
110
+ export function useTimelineQuery({
111
+ client,
112
+ initialTab = "for_you",
113
+ refreshBannerDelay = DEFAULT_REFRESH_BANNER_DELAY,
114
+ }: UseTimelineQueryOptions): UseTimelineQueryResult {
115
+ const [tab, setTabState] = useState<TimelineTab>(initialTab);
116
+ const [showRefreshBanner, setShowRefreshBanner] = useState(false);
117
+ const bannerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
118
+
119
+ // Main infinite query for timeline
120
+ const {
121
+ data,
122
+ isLoading,
123
+ isFetchingNextPage,
124
+ hasNextPage,
125
+ error,
126
+ fetchNextPage,
127
+ refetch,
128
+ isRefetching,
129
+ } = useInfiniteQuery({
130
+ queryKey: queryKeys.timeline.byTab(tab),
131
+ queryFn: async ({ pageParam }) => {
132
+ return fetchTimelinePage(client, tab, pageParam);
133
+ },
134
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
135
+ initialPageParam: undefined as string | undefined,
136
+ staleTime: 0,
137
+ });
138
+
139
+ // Start/reset banner timer when data loads or refreshes
140
+ const resetBannerTimer = useCallback(() => {
141
+ // Clear existing timer
142
+ if (bannerTimerRef.current) {
143
+ clearTimeout(bannerTimerRef.current);
144
+ }
145
+ // Hide banner
146
+ setShowRefreshBanner(false);
147
+ // Start new timer
148
+ bannerTimerRef.current = setTimeout(() => {
149
+ setShowRefreshBanner(true);
150
+ }, refreshBannerDelay);
151
+ }, [refreshBannerDelay]);
152
+
153
+ // Reset timer when data changes (initial load or refresh)
154
+ useEffect(() => {
155
+ if (data?.pages?.length) {
156
+ resetBannerTimer();
157
+ }
158
+ return () => {
159
+ if (bannerTimerRef.current) {
160
+ clearTimeout(bannerTimerRef.current);
161
+ }
162
+ };
163
+ }, [data?.pages, resetBannerTimer]);
164
+
165
+ // Flatten and deduplicate tweets from all pages
166
+ const posts = useMemo(() => {
167
+ if (!data?.pages) return [];
168
+ return deduplicateTweets(data.pages);
169
+ }, [data?.pages]);
170
+
171
+ // Handle tab switch
172
+ const setTab = useCallback(
173
+ (newTab: TimelineTab) => {
174
+ if (newTab !== tab) {
175
+ setTabState(newTab);
176
+ resetBannerTimer();
177
+ }
178
+ },
179
+ [tab, resetBannerTimer]
180
+ );
181
+
182
+ // Manual refresh
183
+ const refresh = useCallback(() => {
184
+ setShowRefreshBanner(false);
185
+ refetch();
186
+ }, [refetch]);
187
+
188
+ return {
189
+ tab,
190
+ setTab,
191
+ posts,
192
+ isLoading,
193
+ isFetchingNextPage,
194
+ hasNextPage: hasNextPage ?? false,
195
+ error: error as ApiError | null,
196
+ fetchNextPage,
197
+ refresh,
198
+ showRefreshBanner,
199
+ isRefetching,
200
+ };
201
+ }
File without changes
@@ -0,0 +1,354 @@
1
+ /**
2
+ * useActions - Hook for tweet action mutations (like, bookmark)
3
+ *
4
+ * Provides toggle functions with optimistic updates and error handling.
5
+ */
6
+
7
+ import { useState, useCallback, useRef } from "react";
8
+
9
+ import type { XClient } from "@/api/client";
10
+ import type { TweetData } from "@/api/types";
11
+
12
+ export interface BookmarkMutationFns {
13
+ /** Add a bookmark with optimistic cache update */
14
+ addBookmark: (tweet: TweetData) => void;
15
+ /** Remove a bookmark with optimistic cache update */
16
+ removeBookmark: (
17
+ tweetId: string,
18
+ folderId?: string,
19
+ tweet?: TweetData
20
+ ) => void;
21
+ /** Check if a bookmark operation is pending */
22
+ isPending: (tweetId: string) => boolean;
23
+ /** Check if a tweet was just bookmarked (for visual feedback) */
24
+ isJustBookmarked: (tweetId: string) => boolean;
25
+ }
26
+
27
+ export interface UseActionsOptions {
28
+ client: XClient;
29
+ /** Callback when an action fails - use to show error message */
30
+ onError?: (error: string) => void;
31
+ /** Callback when an action succeeds - use to show success message */
32
+ onSuccess?: (message: string) => void;
33
+ /** Callback when bookmark state changes - use to sync bookmark list */
34
+ onBookmarkChange?: (tweetId: string, isBookmarked: boolean) => void;
35
+ /** Optional TanStack Query bookmark mutation for optimistic cache updates */
36
+ bookmarkMutation?: BookmarkMutationFns;
37
+ /** Current folder ID for bookmark cache updates */
38
+ currentFolderId?: string;
39
+ }
40
+
41
+ export interface TweetActionState {
42
+ /** Whether the tweet is liked by the current user */
43
+ liked: boolean;
44
+ /** Whether the tweet is bookmarked by the current user */
45
+ bookmarked: boolean;
46
+ /** Whether a like action is in progress */
47
+ likePending: boolean;
48
+ /** Whether a bookmark action is in progress */
49
+ bookmarkPending: boolean;
50
+ /** True briefly after liking (for visual feedback) */
51
+ justLiked: boolean;
52
+ /** True briefly after bookmarking (for visual feedback) */
53
+ justBookmarked: boolean;
54
+ }
55
+
56
+ export interface UseActionsResult {
57
+ /** Get current action state for a tweet */
58
+ getState: (tweetId: string) => TweetActionState;
59
+ /** Toggle like state for a tweet */
60
+ toggleLike: (tweet: TweetData) => Promise<void>;
61
+ /** Toggle bookmark state for a tweet */
62
+ toggleBookmark: (tweet: TweetData) => Promise<void>;
63
+ /** Initialize state for a tweet (e.g., from API response) */
64
+ initState: (tweetId: string, liked: boolean, bookmarked: boolean) => void;
65
+ }
66
+
67
+ const DEFAULT_STATE: TweetActionState = {
68
+ liked: false,
69
+ bookmarked: false,
70
+ likePending: false,
71
+ bookmarkPending: false,
72
+ justLiked: false,
73
+ justBookmarked: false,
74
+ };
75
+
76
+ /** Duration for "just acted" visual feedback in ms */
77
+ const JUST_ACTED_DURATION = 600;
78
+
79
+ export function useActions({
80
+ client,
81
+ onError,
82
+ onSuccess,
83
+ onBookmarkChange,
84
+ bookmarkMutation,
85
+ currentFolderId,
86
+ }: UseActionsOptions): UseActionsResult {
87
+ // Track action states by tweet ID
88
+ const [states, setStates] = useState<Map<string, TweetActionState>>(
89
+ new Map()
90
+ );
91
+
92
+ // Track timeouts for clearing "just acted" states
93
+ const justLikedTimeouts = useRef<Map<string, ReturnType<typeof setTimeout>>>(
94
+ new Map()
95
+ );
96
+ const justBookmarkedTimeouts = useRef<
97
+ Map<string, ReturnType<typeof setTimeout>>
98
+ >(new Map());
99
+
100
+ const getState = useCallback(
101
+ (tweetId: string): TweetActionState => {
102
+ const baseState = states.get(tweetId) ?? DEFAULT_STATE;
103
+
104
+ // If using TanStack Query mutation, overlay its pending/justBookmarked state
105
+ if (bookmarkMutation) {
106
+ return {
107
+ ...baseState,
108
+ bookmarkPending: bookmarkMutation.isPending(tweetId),
109
+ justBookmarked: bookmarkMutation.isJustBookmarked(tweetId),
110
+ };
111
+ }
112
+
113
+ return baseState;
114
+ },
115
+ [states, bookmarkMutation]
116
+ );
117
+
118
+ const updateState = useCallback(
119
+ (tweetId: string, updates: Partial<TweetActionState>) => {
120
+ setStates((prev) => {
121
+ const newMap = new Map(prev);
122
+ const current = prev.get(tweetId) ?? DEFAULT_STATE;
123
+ newMap.set(tweetId, { ...current, ...updates });
124
+ return newMap;
125
+ });
126
+ },
127
+ []
128
+ );
129
+
130
+ const initState = useCallback(
131
+ (tweetId: string, liked: boolean, bookmarked: boolean) => {
132
+ setStates((prev) => {
133
+ // Only initialize if no state exists yet for this tweet
134
+ // This preserves user actions (like/bookmark) when navigating back
135
+ if (prev.has(tweetId)) {
136
+ return prev;
137
+ }
138
+ const newMap = new Map(prev);
139
+ newMap.set(tweetId, { ...DEFAULT_STATE, liked, bookmarked });
140
+ return newMap;
141
+ });
142
+ },
143
+ []
144
+ );
145
+
146
+ const toggleLike = useCallback(
147
+ async (tweet: TweetData) => {
148
+ const currentState = states.get(tweet.id) ?? DEFAULT_STATE;
149
+
150
+ // Prevent double-clicks
151
+ if (currentState.likePending) return;
152
+
153
+ const wasLiked = currentState.liked;
154
+ const newLiked = !wasLiked;
155
+
156
+ // Optimistic update
157
+ updateState(tweet.id, { liked: newLiked, likePending: true });
158
+
159
+ try {
160
+ const result = newLiked
161
+ ? await client.likeTweet(tweet.id)
162
+ : await client.unlikeTweet(tweet.id);
163
+
164
+ if (result.success) {
165
+ // Set justLiked for visual feedback (only when liking)
166
+ if (newLiked) {
167
+ // Clear any existing timeout
168
+ const existingTimeout = justLikedTimeouts.current.get(tweet.id);
169
+ if (existingTimeout) clearTimeout(existingTimeout);
170
+
171
+ updateState(tweet.id, { likePending: false, justLiked: true });
172
+
173
+ // Auto-clear justLiked after duration
174
+ const timeout = setTimeout(() => {
175
+ updateState(tweet.id, { justLiked: false });
176
+ justLikedTimeouts.current.delete(tweet.id);
177
+ }, JUST_ACTED_DURATION);
178
+ justLikedTimeouts.current.set(tweet.id, timeout);
179
+ } else {
180
+ updateState(tweet.id, { likePending: false });
181
+ }
182
+ onSuccess?.(newLiked ? "Liked" : "Unliked");
183
+ } else {
184
+ // Check if error indicates the tweet was already in the target state
185
+ const lowerError = result.error.toLowerCase();
186
+ const alreadyLiked = lowerError.includes("already favorited");
187
+ const notLiked = lowerError.includes("not found");
188
+ const tweetDeleted =
189
+ lowerError.includes("deleted") ||
190
+ lowerError.includes("unavailable") ||
191
+ lowerError.includes("no status found");
192
+
193
+ if (alreadyLiked && newLiked) {
194
+ // We tried to like but it was already liked - sync state
195
+ updateState(tweet.id, { liked: true, likePending: false });
196
+ onSuccess?.("Already liked");
197
+ } else if (notLiked && !newLiked) {
198
+ // We tried to unlike but it wasn't liked - sync state
199
+ updateState(tweet.id, { liked: false, likePending: false });
200
+ onSuccess?.("Already unliked");
201
+ } else if (tweetDeleted) {
202
+ // Tweet was deleted - clear state and show friendly message
203
+ updateState(tweet.id, { liked: false, likePending: false });
204
+ onError?.("Tweet no longer available");
205
+ } else {
206
+ // Real error - revert
207
+ updateState(tweet.id, { liked: wasLiked, likePending: false });
208
+ onError?.(result.error);
209
+ }
210
+ }
211
+ } catch (error) {
212
+ // Revert on error
213
+ updateState(tweet.id, { liked: wasLiked, likePending: false });
214
+ onError?.(error instanceof Error ? error.message : String(error));
215
+ }
216
+ },
217
+ [client, states, updateState, onError, onSuccess]
218
+ );
219
+
220
+ const toggleBookmark = useCallback(
221
+ async (tweet: TweetData) => {
222
+ const currentState = states.get(tweet.id) ?? DEFAULT_STATE;
223
+
224
+ // If using TanStack Query mutation, delegate to it
225
+ if (bookmarkMutation) {
226
+ // Prevent double-clicks
227
+ if (bookmarkMutation.isPending(tweet.id)) return;
228
+
229
+ const wasBookmarked = currentState.bookmarked;
230
+ const newBookmarked = !wasBookmarked;
231
+
232
+ // Update local state for UI (bookmarked flag)
233
+ updateState(tweet.id, { bookmarked: newBookmarked });
234
+
235
+ // Use mutation for API call + cache updates
236
+ if (newBookmarked) {
237
+ bookmarkMutation.addBookmark(tweet);
238
+ } else {
239
+ bookmarkMutation.removeBookmark(tweet.id, currentFolderId, tweet);
240
+ }
241
+
242
+ // Notify about bookmark change (for legacy sync if needed)
243
+ onBookmarkChange?.(tweet.id, newBookmarked);
244
+ return;
245
+ }
246
+
247
+ // Fallback: Original implementation without TanStack Query
248
+ // Prevent double-clicks
249
+ if (currentState.bookmarkPending) return;
250
+
251
+ const wasBookmarked = currentState.bookmarked;
252
+ const newBookmarked = !wasBookmarked;
253
+
254
+ // Optimistic update
255
+ updateState(tweet.id, {
256
+ bookmarked: newBookmarked,
257
+ bookmarkPending: true,
258
+ });
259
+
260
+ try {
261
+ const result = newBookmarked
262
+ ? await client.createBookmark(tweet.id)
263
+ : await client.deleteBookmark(tweet.id);
264
+
265
+ if (result.success) {
266
+ // Set justBookmarked for visual feedback (only when bookmarking)
267
+ if (newBookmarked) {
268
+ // Clear any existing timeout
269
+ const existingTimeout = justBookmarkedTimeouts.current.get(
270
+ tweet.id
271
+ );
272
+ if (existingTimeout) clearTimeout(existingTimeout);
273
+
274
+ updateState(tweet.id, {
275
+ bookmarkPending: false,
276
+ justBookmarked: true,
277
+ });
278
+
279
+ // Auto-clear justBookmarked after duration
280
+ const timeout = setTimeout(() => {
281
+ updateState(tweet.id, { justBookmarked: false });
282
+ justBookmarkedTimeouts.current.delete(tweet.id);
283
+ }, JUST_ACTED_DURATION);
284
+ justBookmarkedTimeouts.current.set(tweet.id, timeout);
285
+ } else {
286
+ updateState(tweet.id, { bookmarkPending: false });
287
+ }
288
+ onSuccess?.(newBookmarked ? "Bookmarked" : "Removed bookmark");
289
+ onBookmarkChange?.(tweet.id, newBookmarked);
290
+ } else {
291
+ // Check if error indicates the tweet was already in the target state
292
+ const lowerError = result.error.toLowerCase();
293
+ const alreadyBookmarked = lowerError.includes("already bookmarked");
294
+ const notBookmarked = lowerError.includes("not found");
295
+ const tweetDeleted =
296
+ lowerError.includes("deleted") ||
297
+ lowerError.includes("unavailable") ||
298
+ lowerError.includes("no status found");
299
+
300
+ if (alreadyBookmarked && newBookmarked) {
301
+ // We tried to bookmark but it was already bookmarked - sync state
302
+ updateState(tweet.id, { bookmarked: true, bookmarkPending: false });
303
+ onSuccess?.("Already bookmarked");
304
+ } else if (notBookmarked && !newBookmarked) {
305
+ // We tried to unbookmark but it wasn't bookmarked - sync state
306
+ updateState(tweet.id, {
307
+ bookmarked: false,
308
+ bookmarkPending: false,
309
+ });
310
+ onSuccess?.("Already removed");
311
+ } else if (tweetDeleted) {
312
+ // Tweet was deleted - clear state and show friendly message
313
+ updateState(tweet.id, {
314
+ bookmarked: false,
315
+ bookmarkPending: false,
316
+ });
317
+ onError?.("Tweet no longer available");
318
+ } else {
319
+ // Real error - revert
320
+ updateState(tweet.id, {
321
+ bookmarked: wasBookmarked,
322
+ bookmarkPending: false,
323
+ });
324
+ onError?.(result.error);
325
+ }
326
+ }
327
+ } catch (error) {
328
+ // Revert on error
329
+ updateState(tweet.id, {
330
+ bookmarked: wasBookmarked,
331
+ bookmarkPending: false,
332
+ });
333
+ onError?.(error instanceof Error ? error.message : String(error));
334
+ }
335
+ },
336
+ [
337
+ client,
338
+ states,
339
+ updateState,
340
+ onError,
341
+ onSuccess,
342
+ onBookmarkChange,
343
+ bookmarkMutation,
344
+ currentFolderId,
345
+ ]
346
+ );
347
+
348
+ return {
349
+ getState,
350
+ toggleLike,
351
+ toggleBookmark,
352
+ initState,
353
+ };
354
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * useBookmarkFolders - Hook for fetching bookmark folder list
3
+ */
4
+
5
+ import { useState, useEffect, useCallback } from "react";
6
+
7
+ import type { XClient } from "@/api/client";
8
+ import type { BookmarkFolder } from "@/api/types";
9
+
10
+ export interface UseBookmarkFoldersOptions {
11
+ client: XClient;
12
+ /** Whether to fetch folders on mount (default: true) */
13
+ enabled?: boolean;
14
+ }
15
+
16
+ export interface UseBookmarkFoldersResult {
17
+ /** List of bookmark folders */
18
+ folders: BookmarkFolder[];
19
+ /** Whether data is currently loading */
20
+ loading: boolean;
21
+ /** Error message if fetch failed */
22
+ error: string | null;
23
+ /** Manually trigger a refresh */
24
+ refresh: () => void;
25
+ }
26
+
27
+ export function useBookmarkFolders({
28
+ client,
29
+ enabled = true,
30
+ }: UseBookmarkFoldersOptions): UseBookmarkFoldersResult {
31
+ const [folders, setFolders] = useState<BookmarkFolder[]>([]);
32
+ const [loading, setLoading] = useState(false);
33
+ const [error, setError] = useState<string | null>(null);
34
+ const [refreshCounter, setRefreshCounter] = useState(0);
35
+
36
+ const fetchFolders = useCallback(async () => {
37
+ if (!enabled) return;
38
+
39
+ setLoading(true);
40
+ setError(null);
41
+
42
+ const result = await client.getBookmarkFolders();
43
+
44
+ if (result.success) {
45
+ setFolders(result.folders);
46
+ } else {
47
+ setError(result.error);
48
+ }
49
+
50
+ setLoading(false);
51
+ }, [client, enabled]);
52
+
53
+ // Fetch on mount and when refresh is triggered
54
+ useEffect(() => {
55
+ if (enabled) {
56
+ fetchFolders();
57
+ }
58
+ }, [fetchFolders, refreshCounter, enabled]);
59
+
60
+ const refresh = useCallback(() => {
61
+ setRefreshCounter((prev) => prev + 1);
62
+ }, []);
63
+
64
+ return {
65
+ folders,
66
+ loading,
67
+ error,
68
+ refresh,
69
+ };
70
+ }