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,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
|
+
}
|