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,171 @@
1
+ # Modal System
2
+
3
+ Type-safe modal management using React Context with discriminated unions.
4
+
5
+ ## Usage
6
+
7
+ ### Opening a Modal
8
+
9
+ ```typescript
10
+ import { useModal } from "@/contexts/ModalContext";
11
+
12
+ function MyComponent() {
13
+ const { openModal, closeModal } = useModal();
14
+
15
+ const handleClick = () => {
16
+ openModal("exit-confirmation", {
17
+ onConfirm: () => console.log("Confirmed!"),
18
+ onCancel: closeModal,
19
+ });
20
+ };
21
+ }
22
+ ```
23
+
24
+ ### Available Modal Types
25
+
26
+ | Type | Props | Description |
27
+ | -------------------------- | ------------------------------------------------ | ------------------------------ |
28
+ | `folder-picker` | `client`, `tweet`, `onSelect`, `onClose` | Move bookmark to folder |
29
+ | `bookmark-folder-selector` | `client`, `currentFolder`, `onSelect`, `onClose` | Select folder to view |
30
+ | `exit-confirmation` | `onConfirm`, `onCancel` | Confirm app exit |
31
+ | `session-expired` | none | Terminal session expired state |
32
+
33
+ ### Context Values
34
+
35
+ ```typescript
36
+ const {
37
+ openModal, // Open a modal with type-safe props
38
+ closeModal, // Close current modal (no-op for session-expired)
39
+ isModalOpen, // Boolean for keyboard gating
40
+ activeModal, // Current modal state (for advanced use)
41
+ } = useModal();
42
+ ```
43
+
44
+ ## Adding a New Modal
45
+
46
+ ### 1. Create the modal component
47
+
48
+ ```typescript
49
+ // src/modals/MyNewModal.tsx
50
+ import { useKeyboard } from "@opentui/react";
51
+
52
+ interface MyNewModalProps {
53
+ someData: string;
54
+ onConfirm: () => void;
55
+ onClose: () => void;
56
+ focused?: boolean;
57
+ }
58
+
59
+ export function MyNewModal({ someData, onConfirm, onClose, focused = true }: MyNewModalProps) {
60
+ useKeyboard((key) => {
61
+ if (!focused) return; // Always guard with focused
62
+
63
+ if (key.name === "escape") onClose();
64
+ if (key.name === "return") onConfirm();
65
+ });
66
+
67
+ return (
68
+ <box style={{ /* modal styling */ }}>
69
+ {/* Modal content */}
70
+ </box>
71
+ );
72
+ }
73
+ ```
74
+
75
+ ### 2. Add types to ModalContext
76
+
77
+ ```typescript
78
+ // src/contexts/ModalContext.tsx
79
+
80
+ // Add props interface
81
+ export interface MyNewModalProps {
82
+ someData: string;
83
+ onConfirm: () => void;
84
+ onClose: () => void;
85
+ }
86
+
87
+ // Add to ModalType union
88
+ export type ModalType =
89
+ | "folder-picker"
90
+ | "bookmark-folder-selector"
91
+ | "exit-confirmation"
92
+ | "session-expired"
93
+ | "my-new-modal"; // Add here
94
+
95
+ // Add to ModalPropsMap
96
+ export interface ModalPropsMap {
97
+ // ... existing entries
98
+ "my-new-modal": MyNewModalProps;
99
+ }
100
+
101
+ // Add to ModalState union
102
+ export type ModalState =
103
+ // ... existing entries
104
+ | { type: "my-new-modal"; props: MyNewModalProps }
105
+ | null;
106
+ ```
107
+
108
+ ### 3. Add to ModalRenderer switch
109
+
110
+ ```typescript
111
+ // In ModalRenderer component
112
+ case "my-new-modal":
113
+ return (
114
+ <ModalWrapper>
115
+ <MyNewModal {...activeModal.props} focused={true} />
116
+ </ModalWrapper>
117
+ );
118
+ ```
119
+
120
+ ### 4. Export from index
121
+
122
+ ```typescript
123
+ // src/modals/index.ts
124
+ export { MyNewModal } from "./MyNewModal";
125
+ ```
126
+
127
+ ## Important Patterns
128
+
129
+ ### Keyboard Gating
130
+
131
+ Screens must include `!isModalOpen` in their `focused` prop to prevent keyboard events from propagating when modals are open:
132
+
133
+ ```typescript
134
+ <TimelineScreen
135
+ focused={currentView === "timeline" && !showSplash && !isModalOpen}
136
+ />
137
+ ```
138
+
139
+ ### Callback Closures
140
+
141
+ Callbacks passed to `openModal` capture state at call time. This is intentional - the modal operates on the data that was current when it opened:
142
+
143
+ ```typescript
144
+ openModal("folder-picker", {
145
+ tweet: selectedPost, // Captured value
146
+ onSelect: async (folderId) => {
147
+ // selectedPost here is the captured value
148
+ await client.moveBookmarkToFolder(selectedPost.id, folderId);
149
+ closeModal();
150
+ },
151
+ });
152
+ ```
153
+
154
+ ### Terminal Modals
155
+
156
+ `session-expired` is a terminal modal - `closeModal()` is a no-op when it's active. The only way out is to exit the app.
157
+
158
+ ## File Structure
159
+
160
+ ```
161
+ src/
162
+ ├── contexts/
163
+ │ └── ModalContext.tsx # Context, Provider, useModal, ModalRenderer
164
+ ├── modals/
165
+ │ ├── README.md # This file
166
+ │ ├── index.ts # Re-exports
167
+ │ ├── FolderPicker.tsx
168
+ │ ├── BookmarkFolderSelector.tsx
169
+ │ ├── ExitConfirmationModal.tsx
170
+ │ └── SessionExpiredModal.tsx
171
+ ```
@@ -0,0 +1,47 @@
1
+ import { useKeyboard, useRenderer } from "@opentui/react";
2
+
3
+ import { colors } from "@/lib/colors";
4
+
5
+ export function SessionExpiredModal() {
6
+ const renderer = useRenderer();
7
+
8
+ useKeyboard((key) => {
9
+ if (key.name === "return" || key.name === "q" || key.name === "escape") {
10
+ renderer.destroy();
11
+ }
12
+ });
13
+
14
+ return (
15
+ <box
16
+ style={{
17
+ position: "absolute",
18
+ top: 0,
19
+ left: 0,
20
+ width: "100%",
21
+ height: "100%",
22
+ justifyContent: "center",
23
+ alignItems: "center",
24
+ }}
25
+ >
26
+ <box
27
+ style={{
28
+ borderStyle: "single",
29
+ padding: 2,
30
+ flexDirection: "column",
31
+ alignItems: "center",
32
+ gap: 1,
33
+ }}
34
+ backgroundColor={colors.selectedBg}
35
+ borderColor={colors.error}
36
+ >
37
+ <text fg={colors.error}>Session Expired</text>
38
+ <text fg="#a8a8a8">Your tokens are no longer valid.</text>
39
+ <box style={{ marginTop: 1, flexDirection: "row" }}>
40
+ <text fg="#6b6b6b">Press </text>
41
+ <text fg="#ffffff">Enter</text>
42
+ <text fg="#6b6b6b"> to quit</text>
43
+ </box>
44
+ </box>
45
+ </box>
46
+ );
47
+ }
@@ -0,0 +1,4 @@
1
+ export { BookmarkFolderSelector } from "./BookmarkFolderSelector";
2
+ export { ExitConfirmationModal } from "./ExitConfirmationModal";
3
+ export { FolderPicker } from "./FolderPicker";
4
+ export { SessionExpiredModal } from "./SessionExpiredModal";
File without changes
@@ -0,0 +1,168 @@
1
+ /**
2
+ * BookmarksScreen - Displays the user's bookmarked posts
3
+ * Includes error handling with ErrorBanner for rate limits, auth expiry, etc.
4
+ * Supports viewing all bookmarks or a specific folder.
5
+ */
6
+
7
+ import { useKeyboard } from "@opentui/react";
8
+ import { useEffect } from "react";
9
+
10
+ import type { XClient } from "@/api/client";
11
+ import type { BookmarkFolder, TweetData } from "@/api/types";
12
+ import type { TweetActionState } from "@/hooks/useActions";
13
+
14
+ import { ErrorBanner } from "@/components/ErrorBanner";
15
+ import { PostList } from "@/components/PostList";
16
+ import { useBookmarksQuery } from "@/experiments";
17
+ import { colors } from "@/lib/colors";
18
+
19
+ interface BookmarksScreenProps {
20
+ client: XClient;
21
+ focused?: boolean;
22
+ /** Currently selected folder (null = all bookmarks) */
23
+ selectedFolder?: BookmarkFolder | null;
24
+ /** Called when user wants to open the folder picker (press 'f') */
25
+ onFolderPickerOpen?: () => void;
26
+ onPostCountChange?: (count: number) => void;
27
+ onHasMoreChange?: (hasMore: boolean) => void;
28
+ onPostSelect?: (post: TweetData) => void;
29
+ /** Called when user presses 'l' to toggle like */
30
+ onLike?: (post: TweetData) => void;
31
+ /** Called when user presses 'b' to toggle bookmark */
32
+ onBookmark?: (post: TweetData) => void;
33
+ /** Get current action state for a tweet */
34
+ getActionState?: (tweetId: string) => TweetActionState;
35
+ /** Initialize action state from API data */
36
+ initActionState?: (
37
+ tweetId: string,
38
+ liked: boolean,
39
+ bookmarked: boolean
40
+ ) => void;
41
+ }
42
+
43
+ interface ScreenHeaderProps {
44
+ folderName?: string | null;
45
+ }
46
+
47
+ function ScreenHeader({ folderName }: ScreenHeaderProps) {
48
+ const title = folderName ?? "All Bookmarks";
49
+ return (
50
+ <box
51
+ style={{
52
+ flexShrink: 0,
53
+ paddingLeft: 1,
54
+ paddingRight: 1,
55
+ paddingBottom: 1,
56
+ flexDirection: "row",
57
+ }}
58
+ >
59
+ <text fg={colors.primary}>
60
+ <b>{title}</b>
61
+ </text>
62
+ <text fg={colors.dim}> (f to switch folders)</text>
63
+ </box>
64
+ );
65
+ }
66
+
67
+ export function BookmarksScreen({
68
+ client,
69
+ focused = false,
70
+ selectedFolder,
71
+ onFolderPickerOpen,
72
+ onPostCountChange,
73
+ onHasMoreChange,
74
+ onPostSelect,
75
+ onLike,
76
+ onBookmark,
77
+ getActionState,
78
+ initActionState,
79
+ }: BookmarksScreenProps) {
80
+ const {
81
+ posts,
82
+ isLoading,
83
+ isFetchingNextPage,
84
+ hasNextPage,
85
+ error,
86
+ refresh,
87
+ fetchNextPage,
88
+ } = useBookmarksQuery({ client, folderId: selectedFolder?.id });
89
+
90
+ // Report post count to parent
91
+ useEffect(() => {
92
+ onPostCountChange?.(posts.length);
93
+ }, [posts.length, onPostCountChange]);
94
+
95
+ // Report hasMore state to parent
96
+ useEffect(() => {
97
+ onHasMoreChange?.(hasNextPage);
98
+ }, [hasNextPage, onHasMoreChange]);
99
+
100
+ // Handle keyboard shortcuts
101
+ useKeyboard((key) => {
102
+ if (!focused) return;
103
+
104
+ if (key.name === "r") {
105
+ refresh();
106
+ }
107
+
108
+ // Open folder picker with 'f'
109
+ if (key.name === "f") {
110
+ onFolderPickerOpen?.();
111
+ }
112
+ });
113
+
114
+ const folderName = selectedFolder?.name ?? null;
115
+
116
+ if (isLoading) {
117
+ return (
118
+ <box style={{ flexDirection: "column", height: "100%" }}>
119
+ <ScreenHeader folderName={folderName} />
120
+ <box style={{ padding: 2, flexGrow: 1 }}>
121
+ <text fg={colors.muted}>Loading bookmarks...</text>
122
+ </box>
123
+ </box>
124
+ );
125
+ }
126
+
127
+ if (error) {
128
+ return (
129
+ <box style={{ flexDirection: "column", height: "100%" }}>
130
+ <ScreenHeader folderName={folderName} />
131
+ <ErrorBanner error={error} onRetry={refresh} retryDisabled={false} />
132
+ </box>
133
+ );
134
+ }
135
+
136
+ if (posts.length === 0) {
137
+ return (
138
+ <box style={{ flexDirection: "column", height: "100%" }}>
139
+ <ScreenHeader folderName={folderName} />
140
+ <box style={{ padding: 2, flexGrow: 1 }}>
141
+ <text fg={colors.muted}>
142
+ {selectedFolder
143
+ ? "No bookmarks in this folder. Press r to refresh."
144
+ : "No bookmarks yet. Press r to refresh."}
145
+ </text>
146
+ </box>
147
+ </box>
148
+ );
149
+ }
150
+
151
+ return (
152
+ <box style={{ flexDirection: "column", height: "100%" }}>
153
+ <ScreenHeader folderName={folderName} />
154
+ <PostList
155
+ posts={posts}
156
+ focused={focused}
157
+ onPostSelect={onPostSelect}
158
+ onLike={onLike}
159
+ onBookmark={onBookmark}
160
+ getActionState={getActionState}
161
+ initActionState={initActionState}
162
+ onLoadMore={fetchNextPage}
163
+ loadingMore={isFetchingNextPage}
164
+ hasMore={hasNextPage}
165
+ />
166
+ </box>
167
+ );
168
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * NotificationsScreen - Displays the user's notifications
3
+ * Uses TanStack Query for data fetching with background polling
4
+ */
5
+
6
+ import { useKeyboard } from "@opentui/react";
7
+ import { useEffect } from "react";
8
+
9
+ import type { XClient } from "@/api/client";
10
+ import type { NotificationData } from "@/api/types";
11
+
12
+ import { ErrorBanner } from "@/components/ErrorBanner";
13
+ import { NotificationList } from "@/components/NotificationList";
14
+ import { useNotificationsQuery } from "@/experiments/use-notifications-query";
15
+ import { colors } from "@/lib/colors";
16
+
17
+ interface NotificationsScreenProps {
18
+ client: XClient;
19
+ focused?: boolean;
20
+ onNotificationCountChange?: (count: number) => void;
21
+ onUnreadCountChange?: (count: number) => void;
22
+ onNotificationSelect?: (notification: NotificationData) => void;
23
+ }
24
+
25
+ interface ScreenHeaderProps {
26
+ unreadCount: number;
27
+ isRefetching: boolean;
28
+ }
29
+
30
+ function ScreenHeader({ unreadCount, isRefetching }: ScreenHeaderProps) {
31
+ return (
32
+ <box
33
+ style={{
34
+ flexShrink: 0,
35
+ paddingLeft: 1,
36
+ paddingRight: 1,
37
+ paddingBottom: 1,
38
+ flexDirection: "row",
39
+ justifyContent: "space-between",
40
+ }}
41
+ >
42
+ <box style={{ flexDirection: "row" }}>
43
+ <text fg={colors.primary}>
44
+ <b>Notifications</b>
45
+ </text>
46
+ {unreadCount > 0 && <text fg={colors.error}> ({unreadCount} new)</text>}
47
+ </box>
48
+ {isRefetching && (
49
+ <text fg={colors.muted}>
50
+ <i>syncing...</i>
51
+ </text>
52
+ )}
53
+ </box>
54
+ );
55
+ }
56
+
57
+ function NewNotificationsBanner({ count }: { count: number }) {
58
+ return (
59
+ <box
60
+ style={{
61
+ flexShrink: 0,
62
+ paddingLeft: 1,
63
+ paddingRight: 1,
64
+ paddingTop: 0,
65
+ paddingBottom: 1,
66
+ }}
67
+ >
68
+ <box
69
+ style={{
70
+ backgroundColor: colors.primary,
71
+ paddingLeft: 2,
72
+ paddingRight: 2,
73
+ }}
74
+ >
75
+ <text fg="#000000">
76
+ <b>
77
+ {count} new notification{count > 1 ? "s" : ""} — Press r
78
+ </b>
79
+ </text>
80
+ </box>
81
+ </box>
82
+ );
83
+ }
84
+
85
+ export function NotificationsScreen({
86
+ client,
87
+ focused = false,
88
+ onNotificationCountChange,
89
+ onUnreadCountChange,
90
+ onNotificationSelect,
91
+ }: NotificationsScreenProps) {
92
+ const {
93
+ notifications,
94
+ unreadCount,
95
+ isLoading,
96
+ isFetchingNextPage,
97
+ hasNextPage,
98
+ error,
99
+ fetchNextPage,
100
+ refresh,
101
+ newNotificationsCount,
102
+ isRefetching,
103
+ } = useNotificationsQuery({ client });
104
+
105
+ // Report counts to parent
106
+ useEffect(() => {
107
+ onNotificationCountChange?.(notifications.length);
108
+ }, [notifications.length, onNotificationCountChange]);
109
+
110
+ useEffect(() => {
111
+ onUnreadCountChange?.(unreadCount);
112
+ }, [unreadCount, onUnreadCountChange]);
113
+
114
+ // Handle keyboard shortcuts for refresh
115
+ useKeyboard((key) => {
116
+ if (!focused) return;
117
+
118
+ if (key.name === "r") {
119
+ refresh();
120
+ }
121
+ });
122
+
123
+ if (isLoading) {
124
+ return (
125
+ <box style={{ flexDirection: "column", height: "100%" }}>
126
+ <ScreenHeader unreadCount={0} isRefetching={false} />
127
+ <box style={{ padding: 2, flexGrow: 1 }}>
128
+ <text fg={colors.muted}>Loading notifications...</text>
129
+ </box>
130
+ </box>
131
+ );
132
+ }
133
+
134
+ if (error) {
135
+ return (
136
+ <box style={{ flexDirection: "column", height: "100%" }}>
137
+ <ScreenHeader unreadCount={0} isRefetching={isRefetching} />
138
+ <ErrorBanner error={error} onRetry={refresh} retryDisabled={false} />
139
+ </box>
140
+ );
141
+ }
142
+
143
+ if (notifications.length === 0) {
144
+ return (
145
+ <box style={{ flexDirection: "column", height: "100%" }}>
146
+ <ScreenHeader unreadCount={0} isRefetching={isRefetching} />
147
+ <box style={{ padding: 2, flexGrow: 1 }}>
148
+ <text fg={colors.muted}>
149
+ No notifications yet. Press r to refresh.
150
+ </text>
151
+ </box>
152
+ </box>
153
+ );
154
+ }
155
+
156
+ return (
157
+ <box style={{ flexDirection: "column", height: "100%" }}>
158
+ <ScreenHeader unreadCount={unreadCount} isRefetching={isRefetching} />
159
+ {newNotificationsCount > 0 && (
160
+ <NewNotificationsBanner count={newNotificationsCount} />
161
+ )}
162
+ <NotificationList
163
+ notifications={notifications}
164
+ focused={focused}
165
+ onNotificationSelect={onNotificationSelect}
166
+ onLoadMore={fetchNextPage}
167
+ loadingMore={isFetchingNextPage}
168
+ hasMore={hasNextPage}
169
+ />
170
+ </box>
171
+ );
172
+ }