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,227 @@
1
+ /**
2
+ * ModalContext - Centralized modal management with type-safe discriminated unions
3
+ *
4
+ * Provides:
5
+ * - ModalProvider: Wraps app, manages state, renders modals at root level
6
+ * - useModal: Hook to open/close modals from any component
7
+ *
8
+ * Usage:
9
+ * const { openModal, closeModal } = useModal();
10
+ * openModal("folder-picker", { client, tweet, onSelect, onClose });
11
+ */
12
+
13
+ import {
14
+ createContext,
15
+ useCallback,
16
+ useContext,
17
+ useState,
18
+ type ReactNode,
19
+ } from "react";
20
+
21
+ import type { XClient } from "@/api/client";
22
+ import type { BookmarkFolder, TweetData } from "@/api/types";
23
+
24
+ import { BookmarkFolderSelector } from "@/modals/BookmarkFolderSelector";
25
+ import { ExitConfirmationModal } from "@/modals/ExitConfirmationModal";
26
+ import { FolderPicker } from "@/modals/FolderPicker";
27
+ import { SessionExpiredModal } from "@/modals/SessionExpiredModal";
28
+
29
+ // ============================================================================
30
+ // Modal Type Definitions
31
+ // ============================================================================
32
+
33
+ /** Modal type identifiers */
34
+ export type ModalType =
35
+ | "folder-picker"
36
+ | "bookmark-folder-selector"
37
+ | "exit-confirmation"
38
+ | "session-expired";
39
+
40
+ /** Props for FolderPicker modal */
41
+ export interface FolderPickerModalProps {
42
+ client: XClient;
43
+ tweet: TweetData;
44
+ onSelect: (folderId: string, folderName: string) => Promise<void>;
45
+ onClose: () => void;
46
+ }
47
+
48
+ /** Props for BookmarkFolderSelector modal */
49
+ export interface BookmarkFolderSelectorModalProps {
50
+ client: XClient;
51
+ currentFolder: BookmarkFolder | null;
52
+ onSelect: (folder: BookmarkFolder | null) => void;
53
+ onClose: () => void;
54
+ }
55
+
56
+ /** Props for ExitConfirmationModal */
57
+ export interface ExitConfirmationModalProps {
58
+ onLogout: () => void;
59
+ onConfirm: () => void;
60
+ onCancel: () => void;
61
+ }
62
+
63
+ /** Props for SessionExpiredModal (terminal - no props needed) */
64
+ export interface SessionExpiredModalProps {}
65
+
66
+ /** Map of modal type to its props type for type inference */
67
+ export interface ModalPropsMap {
68
+ "folder-picker": FolderPickerModalProps;
69
+ "bookmark-folder-selector": BookmarkFolderSelectorModalProps;
70
+ "exit-confirmation": ExitConfirmationModalProps;
71
+ "session-expired": SessionExpiredModalProps;
72
+ }
73
+
74
+ /** Discriminated union of all possible modal states */
75
+ export type ModalState =
76
+ | { type: "folder-picker"; props: FolderPickerModalProps }
77
+ | {
78
+ type: "bookmark-folder-selector";
79
+ props: BookmarkFolderSelectorModalProps;
80
+ }
81
+ | { type: "exit-confirmation"; props: ExitConfirmationModalProps }
82
+ | { type: "session-expired"; props: SessionExpiredModalProps }
83
+ | null;
84
+
85
+ // ============================================================================
86
+ // Context Definition
87
+ // ============================================================================
88
+
89
+ /** Context value exposed to consumers */
90
+ export interface ModalContextValue {
91
+ /** Currently active modal state (null if no modal open) */
92
+ activeModal: ModalState;
93
+
94
+ /** Open a modal with type-safe props */
95
+ openModal: <T extends ModalType>(type: T, props: ModalPropsMap[T]) => void;
96
+
97
+ /** Close the current modal (no-op if session-expired is showing) */
98
+ closeModal: () => void;
99
+
100
+ /** Whether any modal is currently open (for keyboard gating) */
101
+ isModalOpen: boolean;
102
+
103
+ /** Whether the session-expired modal is showing (terminal state) */
104
+ isSessionExpired: boolean;
105
+ }
106
+
107
+ const ModalContext = createContext<ModalContextValue | null>(null);
108
+
109
+ // ============================================================================
110
+ // ModalRenderer Component
111
+ // ============================================================================
112
+
113
+ interface ModalRendererProps {
114
+ activeModal: ModalState;
115
+ }
116
+
117
+ function ModalRenderer({ activeModal }: ModalRendererProps) {
118
+ if (!activeModal) {
119
+ return null;
120
+ }
121
+
122
+ // Absolute positioning wrapper - consistent for all modals
123
+ const ModalWrapper = ({ children }: { children: ReactNode }) => (
124
+ <box
125
+ style={{
126
+ position: "absolute",
127
+ top: 0,
128
+ left: 0,
129
+ width: "100%",
130
+ height: "100%",
131
+ }}
132
+ >
133
+ {children}
134
+ </box>
135
+ );
136
+
137
+ switch (activeModal.type) {
138
+ case "folder-picker":
139
+ return (
140
+ <ModalWrapper>
141
+ <FolderPicker {...activeModal.props} focused={true} />
142
+ </ModalWrapper>
143
+ );
144
+
145
+ case "bookmark-folder-selector":
146
+ return (
147
+ <ModalWrapper>
148
+ <BookmarkFolderSelector {...activeModal.props} focused={true} />
149
+ </ModalWrapper>
150
+ );
151
+
152
+ case "exit-confirmation":
153
+ return (
154
+ <ModalWrapper>
155
+ <ExitConfirmationModal {...activeModal.props} focused={true} />
156
+ </ModalWrapper>
157
+ );
158
+
159
+ case "session-expired":
160
+ // SessionExpiredModal includes its own absolute positioning
161
+ return <SessionExpiredModal />;
162
+ }
163
+ }
164
+
165
+ // ============================================================================
166
+ // ModalProvider Component
167
+ // ============================================================================
168
+
169
+ interface ModalProviderProps {
170
+ children: ReactNode;
171
+ }
172
+
173
+ export function ModalProvider({ children }: ModalProviderProps) {
174
+ const [activeModal, setActiveModal] = useState<ModalState>(null);
175
+
176
+ const openModal = useCallback(
177
+ <T extends ModalType>(type: T, props: ModalPropsMap[T]) => {
178
+ // Type assertion needed due to discriminated union mapping
179
+ setActiveModal({ type, props } as ModalState);
180
+ },
181
+ []
182
+ );
183
+
184
+ const closeModal = useCallback(() => {
185
+ setActiveModal((current) => {
186
+ // Don't allow closing session-expired modal (terminal state)
187
+ if (current?.type === "session-expired") {
188
+ return current;
189
+ }
190
+ return null;
191
+ });
192
+ }, []);
193
+
194
+ const isModalOpen = activeModal !== null;
195
+ const isSessionExpired = activeModal?.type === "session-expired";
196
+
197
+ const contextValue: ModalContextValue = {
198
+ activeModal,
199
+ openModal,
200
+ closeModal,
201
+ isModalOpen,
202
+ isSessionExpired,
203
+ };
204
+
205
+ return (
206
+ <ModalContext.Provider value={contextValue}>
207
+ {children}
208
+ <ModalRenderer activeModal={activeModal} />
209
+ </ModalContext.Provider>
210
+ );
211
+ }
212
+
213
+ // ============================================================================
214
+ // useModal Hook
215
+ // ============================================================================
216
+
217
+ /**
218
+ * Hook to access modal context
219
+ * @throws Error if used outside ModalProvider
220
+ */
221
+ export function useModal(): ModalContextValue {
222
+ const context = useContext(ModalContext);
223
+ if (!context) {
224
+ throw new Error("useModal must be used within a ModalProvider");
225
+ }
226
+ return context;
227
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * TimelineScreenExperimental - TanStack Query version of TimelineScreen
3
+ *
4
+ * Features:
5
+ * - Uses useTimelineQuery for data fetching
6
+ * - Shows "Refresh for new posts" banner after 5 minutes
7
+ */
8
+
9
+ import { useKeyboard } from "@opentui/react";
10
+ import { useEffect } from "react";
11
+
12
+ import type { XClient } from "@/api/client";
13
+ import type { TweetData } from "@/api/types";
14
+ import type { TweetActionState } from "@/hooks/useActions";
15
+
16
+ import { ErrorBanner } from "@/components/ErrorBanner";
17
+ import { PostList } from "@/components/PostList";
18
+ import { colors } from "@/lib/colors";
19
+
20
+ import { type TimelineTab, useTimelineQuery } from "./use-timeline-query";
21
+
22
+ interface TimelineScreenExperimentalProps {
23
+ client: XClient;
24
+ focused?: boolean;
25
+ onPostCountChange?: (count: number) => void;
26
+ onPostSelect?: (post: TweetData) => void;
27
+ onLike?: (post: TweetData) => void;
28
+ onBookmark?: (post: TweetData) => void;
29
+ getActionState?: (tweetId: string) => TweetActionState;
30
+ initActionState?: (
31
+ tweetId: string,
32
+ liked: boolean,
33
+ bookmarked: boolean
34
+ ) => void;
35
+ }
36
+
37
+ interface TabBarProps {
38
+ activeTab: TimelineTab;
39
+ isRefetching: boolean;
40
+ }
41
+
42
+ function TabBar({ activeTab, isRefetching }: TabBarProps) {
43
+ return (
44
+ <box
45
+ style={{
46
+ flexShrink: 0,
47
+ paddingLeft: 1,
48
+ paddingRight: 1,
49
+ paddingBottom: 1,
50
+ flexDirection: "row",
51
+ justifyContent: "space-between",
52
+ }}
53
+ >
54
+ <box style={{ flexDirection: "row" }}>
55
+ <text fg={activeTab === "for_you" ? colors.primary : colors.dim}>
56
+ {activeTab === "for_you" ? <b>[1] For You</b> : " 1 For You"}
57
+ </text>
58
+ <text fg={colors.dim}> | </text>
59
+ <text fg={activeTab === "following" ? colors.primary : colors.dim}>
60
+ {activeTab === "following" ? <b>[2] Following</b> : " 2 Following"}
61
+ </text>
62
+ </box>
63
+
64
+ {/* Sync indicator */}
65
+ {isRefetching && (
66
+ <text fg={colors.muted}>
67
+ <i>syncing...</i>
68
+ </text>
69
+ )}
70
+ </box>
71
+ );
72
+ }
73
+
74
+ function RefreshBanner() {
75
+ return (
76
+ <box
77
+ style={{
78
+ flexShrink: 0,
79
+ paddingLeft: 1,
80
+ paddingRight: 1,
81
+ paddingTop: 0,
82
+ paddingBottom: 1,
83
+ }}
84
+ >
85
+ <box
86
+ style={{
87
+ backgroundColor: colors.primary,
88
+ paddingLeft: 2,
89
+ paddingRight: 2,
90
+ }}
91
+ >
92
+ <text fg="#000000">
93
+ <b>Refresh for new posts — Press r</b>
94
+ </text>
95
+ </box>
96
+ </box>
97
+ );
98
+ }
99
+
100
+ export function TimelineScreenExperimental({
101
+ client,
102
+ focused = false,
103
+ onPostCountChange,
104
+ onPostSelect,
105
+ onLike,
106
+ onBookmark,
107
+ getActionState,
108
+ initActionState,
109
+ }: TimelineScreenExperimentalProps) {
110
+ const {
111
+ tab,
112
+ setTab,
113
+ posts,
114
+ isLoading,
115
+ isFetchingNextPage,
116
+ hasNextPage,
117
+ error,
118
+ fetchNextPage,
119
+ refresh,
120
+ showRefreshBanner,
121
+ isRefetching,
122
+ } = useTimelineQuery({
123
+ client,
124
+ });
125
+
126
+ // Report post count to parent
127
+ useEffect(() => {
128
+ onPostCountChange?.(posts.length);
129
+ }, [posts.length, onPostCountChange]);
130
+
131
+ // Handle keyboard shortcuts
132
+ useKeyboard((key) => {
133
+ if (!focused) return;
134
+
135
+ switch (key.name) {
136
+ case "1":
137
+ setTab("for_you");
138
+ break;
139
+ case "2":
140
+ setTab("following");
141
+ break;
142
+ case "r":
143
+ refresh();
144
+ break;
145
+ }
146
+ });
147
+
148
+ if (isLoading) {
149
+ return (
150
+ <box style={{ flexDirection: "column", height: "100%" }}>
151
+ {focused && <TabBar activeTab={tab} isRefetching={isRefetching} />}
152
+ <box style={{ padding: 2, flexGrow: 1 }}>
153
+ <text fg={colors.muted}>Loading timeline...</text>
154
+ </box>
155
+ </box>
156
+ );
157
+ }
158
+
159
+ if (error) {
160
+ return (
161
+ <box style={{ flexDirection: "column", height: "100%" }}>
162
+ {focused && <TabBar activeTab={tab} isRefetching={isRefetching} />}
163
+ <ErrorBanner error={error} onRetry={refresh} retryDisabled={false} />
164
+ </box>
165
+ );
166
+ }
167
+
168
+ if (posts.length === 0) {
169
+ return (
170
+ <box style={{ flexDirection: "column", height: "100%" }}>
171
+ {focused && <TabBar activeTab={tab} isRefetching={isRefetching} />}
172
+ <box style={{ padding: 2, flexGrow: 1 }}>
173
+ <text fg={colors.muted}>
174
+ No posts to display. Press r to refresh.
175
+ </text>
176
+ </box>
177
+ </box>
178
+ );
179
+ }
180
+
181
+ return (
182
+ <box style={{ flexDirection: "column", height: "100%" }}>
183
+ {focused && <TabBar activeTab={tab} isRefetching={isRefetching} />}
184
+
185
+ {/* Refresh banner */}
186
+ {showRefreshBanner && <RefreshBanner />}
187
+
188
+ <PostList
189
+ posts={posts}
190
+ focused={focused}
191
+ onPostSelect={onPostSelect}
192
+ onLike={onLike}
193
+ onBookmark={onBookmark}
194
+ getActionState={getActionState}
195
+ initActionState={initActionState}
196
+ onLoadMore={fetchNextPage}
197
+ loadingMore={isFetchingNextPage}
198
+ hasMore={hasNextPage}
199
+ />
200
+ </box>
201
+ );
202
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * TanStack Query Experiment Entry Point
3
+ *
4
+ * This file provides the QueryClientProvider wrapper for the experimental
5
+ * TanStack Query-based timeline.
6
+ */
7
+
8
+ import { QueryClientProvider } from "@tanstack/react-query";
9
+ import { type ReactNode, useState } from "react";
10
+
11
+ import { createQueryClient } from "./query-client";
12
+
13
+ // Singleton query client (create once per app lifecycle)
14
+ let queryClientInstance: ReturnType<typeof createQueryClient> | null = null;
15
+
16
+ function getQueryClient() {
17
+ if (!queryClientInstance) {
18
+ queryClientInstance = createQueryClient();
19
+ }
20
+ return queryClientInstance;
21
+ }
22
+
23
+ /**
24
+ * Provider wrapper for TanStack Query
25
+ * Wrap your app root with this to enable TanStack Query hooks
26
+ */
27
+ export function QueryProvider({ children }: { children: ReactNode }) {
28
+ // Use state to ensure stable reference across re-renders
29
+ const [queryClient] = useState(() => getQueryClient());
30
+
31
+ return (
32
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
33
+ );
34
+ }
35
+
36
+ // Re-export components for external use
37
+ export { TimelineScreenExperimental } from "./TimelineScreenExperimental";
38
+ export { useTimelineQuery } from "./use-timeline-query";
39
+ export { useProfileQuery } from "./use-profile-query";
40
+ export { usePostDetailQuery } from "./use-post-detail-query";
41
+ export { useBookmarksQuery } from "./use-bookmarks-query";
42
+ export { useBookmarkMutation } from "./use-bookmark-mutation";
43
+ export { createQueryClient, queryKeys } from "./query-client";
@@ -0,0 +1,132 @@
1
+ /**
2
+ * TanStack Query client configuration for terminal environment
3
+ *
4
+ * Key considerations:
5
+ * - No window focus events in terminal, so disable refetchOnWindowFocus
6
+ * - Custom retry logic for X API rate limits
7
+ * - Stale time configured for timeline freshness
8
+ */
9
+
10
+ import { QueryClient } from "@tanstack/react-query";
11
+
12
+ import type { ApiError } from "@/api/types";
13
+
14
+ /**
15
+ * Custom retry function that respects X API rate limits
16
+ */
17
+ function shouldRetry(failureCount: number, error: unknown): boolean {
18
+ // Type guard for ApiError
19
+ const apiError = error as ApiError | undefined;
20
+
21
+ // Don't retry rate limits - let the UI handle countdown
22
+ if (apiError?.type === "rate_limit") {
23
+ return false;
24
+ }
25
+
26
+ // Don't retry auth errors - need user intervention
27
+ if (apiError?.type === "auth_expired") {
28
+ return false;
29
+ }
30
+
31
+ // Don't retry not found - permanent error
32
+ if (apiError?.type === "not_found") {
33
+ return false;
34
+ }
35
+
36
+ // Retry network errors up to 3 times
37
+ if (apiError?.type === "network_error") {
38
+ return failureCount < 3;
39
+ }
40
+
41
+ // Default: retry up to 2 times for unknown errors
42
+ return failureCount < 2;
43
+ }
44
+
45
+ /**
46
+ * Calculate retry delay with exponential backoff
47
+ */
48
+ function getRetryDelay(attemptIndex: number, error: unknown): number {
49
+ const apiError = error as ApiError | undefined;
50
+
51
+ // Use rate limit retry-after if available
52
+ if (apiError?.type === "rate_limit" && apiError.retryAfter) {
53
+ return apiError.retryAfter * 1000;
54
+ }
55
+
56
+ // Exponential backoff: 1s, 2s, 4s...
57
+ return Math.min(1000 * 2 ** attemptIndex, 30000);
58
+ }
59
+
60
+ /**
61
+ * Create a QueryClient configured for the terminal environment
62
+ */
63
+ export function createQueryClient(): QueryClient {
64
+ return new QueryClient({
65
+ defaultOptions: {
66
+ queries: {
67
+ // Terminal has no window focus, disable this
68
+ refetchOnWindowFocus: false,
69
+
70
+ // Don't refetch when component remounts (preserve scroll position)
71
+ refetchOnMount: false,
72
+
73
+ // Keep data in cache for 5 minutes after last subscriber
74
+ gcTime: 5 * 60 * 1000,
75
+
76
+ // Consider data stale after 30 seconds
77
+ // This enables background refetch on next access
78
+ staleTime: 30 * 1000,
79
+
80
+ // Custom retry logic for X API
81
+ retry: shouldRetry,
82
+ retryDelay: getRetryDelay,
83
+
84
+ // Don't throw errors to error boundaries
85
+ throwOnError: false,
86
+ },
87
+ mutations: {
88
+ // Don't retry mutations by default
89
+ retry: false,
90
+ },
91
+ },
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Query key factory for consistent cache keys
97
+ */
98
+ export const queryKeys = {
99
+ timeline: {
100
+ all: ["timeline"] as const,
101
+ forYou: () => [...queryKeys.timeline.all, "for_you"] as const,
102
+ following: () => [...queryKeys.timeline.all, "following"] as const,
103
+ byTab: (tab: "for_you" | "following") =>
104
+ [...queryKeys.timeline.all, tab] as const,
105
+ },
106
+ bookmarks: {
107
+ all: ["bookmarks"] as const,
108
+ list: (folderId?: string) =>
109
+ folderId
110
+ ? ([...queryKeys.bookmarks.all, "folder", folderId] as const)
111
+ : ([...queryKeys.bookmarks.all, "list"] as const),
112
+ folders: () => [...queryKeys.bookmarks.all, "folders"] as const,
113
+ },
114
+ notifications: {
115
+ all: ["notifications"] as const,
116
+ list: () => [...queryKeys.notifications.all, "list"] as const,
117
+ poll: () => [...queryKeys.notifications.all, "poll"] as const,
118
+ },
119
+ tweet: {
120
+ all: ["tweet"] as const,
121
+ detail: (id: string) => [...queryKeys.tweet.all, id] as const,
122
+ replies: (id: string) => [...queryKeys.tweet.all, id, "replies"] as const,
123
+ },
124
+ user: {
125
+ all: ["user"] as const,
126
+ profile: (username: string) =>
127
+ [...queryKeys.user.all, username, "profile"] as const,
128
+ tweets: (userId: string) =>
129
+ [...queryKeys.user.all, userId, "tweets"] as const,
130
+ likes: () => [...queryKeys.user.all, "likes"] as const,
131
+ },
132
+ } as const;