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,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;
|