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,328 @@
1
+ /**
2
+ * Unit tests for NotificationList component logic
3
+ * Tests list navigation, scroll behavior, and item selection
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+
8
+ import type { NotificationData } from "@/api/types";
9
+
10
+ function createNotification(
11
+ overrides: Partial<NotificationData> = {}
12
+ ): NotificationData {
13
+ return {
14
+ id: overrides.id ?? "notif-1",
15
+ icon: overrides.icon ?? "heart_icon",
16
+ message: overrides.message ?? "Test notification",
17
+ url: overrides.url ?? "https://x.com/notification",
18
+ timestamp: overrides.timestamp ?? "2024-01-01T00:00:00Z",
19
+ sortIndex: overrides.sortIndex ?? "1700000000000",
20
+ };
21
+ }
22
+
23
+ function getNotificationItemId(notificationId: string): string {
24
+ return `notification-${notificationId}`;
25
+ }
26
+
27
+ describe("NotificationList", () => {
28
+ describe("getNotificationItemId", () => {
29
+ it("generates correct element ID for notification", () => {
30
+ expect(getNotificationItemId("123")).toBe("notification-123");
31
+ });
32
+
33
+ it("handles complex notification IDs", () => {
34
+ expect(getNotificationItemId("DrwTuwcW4AAA")).toBe(
35
+ "notification-DrwTuwcW4AAA"
36
+ );
37
+ });
38
+
39
+ it("handles notification IDs with special characters", () => {
40
+ expect(getNotificationItemId("notif-123-abc")).toBe(
41
+ "notification-notif-123-abc"
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("empty state detection", () => {
47
+ it("detects empty notifications array", () => {
48
+ const notifications: NotificationData[] = [];
49
+ const isEmpty = notifications.length === 0;
50
+ expect(isEmpty).toBe(true);
51
+ });
52
+
53
+ it("detects non-empty notifications array", () => {
54
+ const notifications = [createNotification()];
55
+ const isEmpty = notifications.length === 0;
56
+ expect(isEmpty).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe("list navigation", () => {
61
+ it("initial selection is index 0", () => {
62
+ const selectedIndex = 0;
63
+ expect(selectedIndex).toBe(0);
64
+ });
65
+
66
+ it("selection increments on j key (down)", () => {
67
+ const notifications = [
68
+ createNotification({ id: "1" }),
69
+ createNotification({ id: "2" }),
70
+ createNotification({ id: "3" }),
71
+ ];
72
+ let selectedIndex = 0;
73
+
74
+ // Simulate j key press
75
+ selectedIndex = Math.min(selectedIndex + 1, notifications.length - 1);
76
+ expect(selectedIndex).toBe(1);
77
+
78
+ selectedIndex = Math.min(selectedIndex + 1, notifications.length - 1);
79
+ expect(selectedIndex).toBe(2);
80
+ });
81
+
82
+ it("selection decrements on k key (up)", () => {
83
+ // Start at last item (index 2)
84
+ let selectedIndex = 2;
85
+
86
+ // Simulate k key press
87
+ selectedIndex = Math.max(selectedIndex - 1, 0);
88
+ expect(selectedIndex).toBe(1);
89
+
90
+ selectedIndex = Math.max(selectedIndex - 1, 0);
91
+ expect(selectedIndex).toBe(0);
92
+ });
93
+
94
+ it("selection stays at 0 when pressing k at top", () => {
95
+ let selectedIndex = 0;
96
+ selectedIndex = Math.max(selectedIndex - 1, 0);
97
+ expect(selectedIndex).toBe(0);
98
+ });
99
+
100
+ it("selection stays at last when pressing j at bottom", () => {
101
+ const notifications = [
102
+ createNotification({ id: "1" }),
103
+ createNotification({ id: "2" }),
104
+ createNotification({ id: "3" }),
105
+ ];
106
+ let selectedIndex = 2;
107
+ selectedIndex = Math.min(selectedIndex + 1, notifications.length - 1);
108
+ expect(selectedIndex).toBe(2);
109
+ });
110
+
111
+ it("g key goes to first item", () => {
112
+ let selectedIndex = 5;
113
+ // Simulate g key press
114
+ selectedIndex = 0;
115
+ expect(selectedIndex).toBe(0);
116
+ });
117
+
118
+ it("G key goes to last item", () => {
119
+ const notifications = [
120
+ createNotification({ id: "1" }),
121
+ createNotification({ id: "2" }),
122
+ createNotification({ id: "3" }),
123
+ createNotification({ id: "4" }),
124
+ createNotification({ id: "5" }),
125
+ ];
126
+ let selectedIndex = 0;
127
+ // Simulate G key press
128
+ selectedIndex = notifications.length - 1;
129
+ expect(selectedIndex).toBe(4);
130
+ });
131
+ });
132
+
133
+ describe("notification selection callback", () => {
134
+ it("calls onNotificationSelect with correct notification", () => {
135
+ const notifications = [
136
+ createNotification({ id: "1", message: "First" }),
137
+ createNotification({ id: "2", message: "Second" }),
138
+ createNotification({ id: "3", message: "Third" }),
139
+ ];
140
+ const selectedIndex = 1;
141
+
142
+ const selectedNotification = notifications[selectedIndex];
143
+ expect(selectedNotification?.id).toBe("2");
144
+ expect(selectedNotification?.message).toBe("Second");
145
+ });
146
+
147
+ it("handles selection at first item", () => {
148
+ const notifications = [
149
+ createNotification({ id: "1", message: "First" }),
150
+ createNotification({ id: "2", message: "Second" }),
151
+ ];
152
+ const selectedIndex = 0;
153
+
154
+ const selectedNotification = notifications[selectedIndex];
155
+ expect(selectedNotification?.id).toBe("1");
156
+ });
157
+
158
+ it("handles selection at last item", () => {
159
+ const notifications = [
160
+ createNotification({ id: "1", message: "First" }),
161
+ createNotification({ id: "2", message: "Second" }),
162
+ ];
163
+ const selectedIndex = 1;
164
+
165
+ const selectedNotification = notifications[selectedIndex];
166
+ expect(selectedNotification?.id).toBe("2");
167
+ });
168
+ });
169
+
170
+ describe("scroll position management", () => {
171
+ it("calculates top margin as 10% of viewport", () => {
172
+ const viewportHeight = 40;
173
+ const topMargin = Math.max(1, Math.floor(viewportHeight / 10));
174
+ expect(topMargin).toBe(4);
175
+ });
176
+
177
+ it("enforces minimum top margin of 1", () => {
178
+ const viewportHeight = 5;
179
+ const topMargin = Math.max(1, Math.floor(viewportHeight / 10));
180
+ expect(topMargin).toBe(1);
181
+ });
182
+
183
+ it("calculates bottom margin as 33% of viewport", () => {
184
+ const viewportHeight = 30;
185
+ const bottomMargin = Math.max(4, Math.floor(viewportHeight / 3));
186
+ expect(bottomMargin).toBe(10);
187
+ });
188
+
189
+ it("enforces minimum bottom margin of 4", () => {
190
+ const viewportHeight = 9;
191
+ const bottomMargin = Math.max(4, Math.floor(viewportHeight / 3));
192
+ expect(bottomMargin).toBe(4);
193
+ });
194
+ });
195
+
196
+ describe("scroll behavior at boundaries", () => {
197
+ it("scrolls to top when selecting first item", () => {
198
+ const selectedIndex = 0;
199
+ const shouldScrollToTop = selectedIndex === 0;
200
+ expect(shouldScrollToTop).toBe(true);
201
+ });
202
+
203
+ it("scrolls to bottom when selecting last item", () => {
204
+ const notifications = [
205
+ createNotification({ id: "1" }),
206
+ createNotification({ id: "2" }),
207
+ createNotification({ id: "3" }),
208
+ ];
209
+ const selectedIndex = 2;
210
+ const shouldScrollToBottom = selectedIndex === notifications.length - 1;
211
+ expect(shouldScrollToBottom).toBe(true);
212
+ });
213
+
214
+ it("does not scroll to boundary for middle items", () => {
215
+ const notifications = [
216
+ createNotification({ id: "1" }),
217
+ createNotification({ id: "2" }),
218
+ createNotification({ id: "3" }),
219
+ ];
220
+ const selectedIndex: number = 1;
221
+
222
+ const shouldScrollToTop = selectedIndex <= 0;
223
+ const shouldScrollToBottom = selectedIndex >= notifications.length - 1;
224
+
225
+ expect(shouldScrollToTop).toBe(false);
226
+ expect(shouldScrollToBottom).toBe(false);
227
+ });
228
+ });
229
+
230
+ describe("scroll position restoration", () => {
231
+ it("saves scroll position before selection", () => {
232
+ let savedScrollTop = 0;
233
+ const currentScrollTop = 150;
234
+
235
+ // Simulate saving before navigation
236
+ savedScrollTop = currentScrollTop;
237
+ expect(savedScrollTop).toBe(150);
238
+ });
239
+
240
+ it("restores scroll position when gaining focus", () => {
241
+ const savedScrollTop = 150;
242
+ const wasFocused = false;
243
+ const focused = true;
244
+
245
+ const shouldRestore = !wasFocused && focused && savedScrollTop > 0;
246
+ expect(shouldRestore).toBe(true);
247
+ });
248
+
249
+ it("does not restore when already focused", () => {
250
+ const savedScrollTop = 150;
251
+ const wasFocused = true;
252
+ const focused = true;
253
+
254
+ const shouldRestore = !wasFocused && focused && savedScrollTop > 0;
255
+ expect(shouldRestore).toBe(false);
256
+ });
257
+
258
+ it("does not restore when scroll position is 0", () => {
259
+ const savedScrollTop = 0;
260
+ const wasFocused = false;
261
+ const focused = true;
262
+
263
+ const shouldRestore = !wasFocused && focused && savedScrollTop > 0;
264
+ expect(shouldRestore).toBe(false);
265
+ });
266
+ });
267
+
268
+ describe("focus state handling", () => {
269
+ it("navigation is disabled when not focused", () => {
270
+ const focused = false;
271
+ const navigationEnabled = focused;
272
+ expect(navigationEnabled).toBe(false);
273
+ });
274
+
275
+ it("navigation is enabled when focused", () => {
276
+ const focused = true;
277
+ const navigationEnabled = focused;
278
+ expect(navigationEnabled).toBe(true);
279
+ });
280
+ });
281
+
282
+ describe("notification ordering", () => {
283
+ it("notifications are displayed in provided order", () => {
284
+ const notifications = [
285
+ createNotification({ id: "a", sortIndex: "3" }),
286
+ createNotification({ id: "b", sortIndex: "2" }),
287
+ createNotification({ id: "c", sortIndex: "1" }),
288
+ ];
289
+
290
+ // Verify order is preserved
291
+ expect(notifications[0]?.id).toBe("a");
292
+ expect(notifications[1]?.id).toBe("b");
293
+ expect(notifications[2]?.id).toBe("c");
294
+ });
295
+
296
+ it("map produces correct indices", () => {
297
+ const notifications = [
298
+ createNotification({ id: "a" }),
299
+ createNotification({ id: "b" }),
300
+ createNotification({ id: "c" }),
301
+ ];
302
+
303
+ const indices = notifications.map((_, index) => index);
304
+ expect(indices).toEqual([0, 1, 2]);
305
+ });
306
+ });
307
+
308
+ describe("key prop generation", () => {
309
+ it("uses notification.id as key", () => {
310
+ const notification = createNotification({ id: "unique-123" });
311
+ const key = notification.id;
312
+ expect(key).toBe("unique-123");
313
+ });
314
+
315
+ it("keys are unique across notifications", () => {
316
+ const notifications = [
317
+ createNotification({ id: "1" }),
318
+ createNotification({ id: "2" }),
319
+ createNotification({ id: "3" }),
320
+ ];
321
+
322
+ const keys = notifications.map((n) => n.id);
323
+ const uniqueKeys = new Set(keys);
324
+
325
+ expect(uniqueKeys.size).toBe(keys.length);
326
+ });
327
+ });
328
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * NotificationList - Scrollable list of notifications with vim-style navigation
3
+ */
4
+
5
+ import type { ScrollBoxRenderable } from "@opentui/core";
6
+
7
+ import { useEffect, useRef } from "react";
8
+
9
+ import type { NotificationData } from "@/api/types";
10
+
11
+ import { NotificationItem } from "@/components/NotificationItem";
12
+ import { useListNavigation } from "@/hooks/useListNavigation";
13
+ import { colors } from "@/lib/colors";
14
+
15
+ interface NotificationListProps {
16
+ notifications: NotificationData[];
17
+ focused?: boolean;
18
+ onNotificationSelect?: (notification: NotificationData) => void;
19
+ onLoadMore?: () => void;
20
+ loadingMore?: boolean;
21
+ hasMore?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Generate element ID from notification ID for scroll targeting
26
+ */
27
+ function getNotificationItemId(notificationId: string): string {
28
+ return `notification-${notificationId}`;
29
+ }
30
+
31
+ export function NotificationList({
32
+ notifications,
33
+ focused = false,
34
+ onNotificationSelect,
35
+ onLoadMore,
36
+ loadingMore = false,
37
+ hasMore = true,
38
+ }: NotificationListProps) {
39
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
40
+ const savedScrollTop = useRef(0);
41
+ const wasFocused = useRef(focused);
42
+
43
+ // Restore scroll position when gaining focus
44
+ useEffect(() => {
45
+ const scrollbox = scrollRef.current;
46
+ if (!scrollbox) return;
47
+
48
+ if (!wasFocused.current && focused && savedScrollTop.current > 0) {
49
+ scrollbox.scrollTo(savedScrollTop.current);
50
+ }
51
+
52
+ wasFocused.current = focused;
53
+ }, [focused]);
54
+
55
+ const { selectedIndex } = useListNavigation({
56
+ itemCount: notifications.length,
57
+ enabled: focused,
58
+ onSelect: (index) => {
59
+ const notification = notifications[index];
60
+ if (notification) {
61
+ if (scrollRef.current) {
62
+ savedScrollTop.current = scrollRef.current.scrollTop;
63
+ }
64
+ onNotificationSelect?.(notification);
65
+ }
66
+ },
67
+ });
68
+
69
+ // Scroll to keep selected item visible
70
+ useEffect(() => {
71
+ const scrollbox = scrollRef.current;
72
+ if (!scrollbox || notifications.length === 0) return;
73
+
74
+ const selectedNotification = notifications[selectedIndex];
75
+ if (!selectedNotification) return;
76
+
77
+ const targetId = getNotificationItemId(selectedNotification.id);
78
+ const target = scrollbox
79
+ .getChildren()
80
+ .find((child) => child.id === targetId);
81
+ if (!target) return;
82
+
83
+ const relativeY = target.y - scrollbox.y;
84
+ const viewportHeight = scrollbox.viewport.height;
85
+
86
+ const topMargin = Math.max(1, Math.floor(viewportHeight / 10));
87
+ const bottomMargin = Math.max(4, Math.floor(viewportHeight / 3));
88
+
89
+ if (selectedIndex === 0) {
90
+ scrollbox.scrollTo(0);
91
+ return;
92
+ }
93
+
94
+ if (selectedIndex === notifications.length - 1) {
95
+ scrollbox.scrollTo(scrollbox.scrollHeight);
96
+ return;
97
+ }
98
+
99
+ if (relativeY + target.height > viewportHeight - bottomMargin) {
100
+ scrollbox.scrollBy(
101
+ relativeY + target.height - viewportHeight + bottomMargin
102
+ );
103
+ } else if (relativeY < topMargin) {
104
+ scrollbox.scrollBy(relativeY - topMargin);
105
+ }
106
+ }, [selectedIndex, notifications.length]);
107
+
108
+ // Trigger load more when approaching the end of the list
109
+ useEffect(() => {
110
+ if (!onLoadMore || loadingMore || !hasMore || notifications.length === 0)
111
+ return;
112
+
113
+ // Load more when within 5 items of the end
114
+ const threshold = 5;
115
+ if (selectedIndex >= notifications.length - threshold) {
116
+ onLoadMore();
117
+ }
118
+ }, [selectedIndex, notifications.length, onLoadMore, loadingMore, hasMore]);
119
+
120
+ if (notifications.length === 0) {
121
+ return (
122
+ <box style={{ padding: 2 }}>
123
+ <text fg={colors.muted}>No notifications</text>
124
+ </box>
125
+ );
126
+ }
127
+
128
+ return (
129
+ <scrollbox
130
+ ref={scrollRef}
131
+ focused={focused}
132
+ style={{
133
+ flexGrow: 1,
134
+ height: "100%",
135
+ }}
136
+ >
137
+ {notifications.map((notification, index) => (
138
+ <NotificationItem
139
+ key={notification.id}
140
+ id={getNotificationItemId(notification.id)}
141
+ notification={notification}
142
+ isSelected={index === selectedIndex}
143
+ />
144
+ ))}
145
+ {loadingMore ? (
146
+ <box style={{ padding: 1, paddingLeft: 2 }}>
147
+ <text fg={colors.muted}>Loading more...</text>
148
+ </box>
149
+ ) : null}
150
+ {!hasMore && notifications.length > 0 ? (
151
+ <box style={{ padding: 1, paddingLeft: 2 }}>
152
+ <text fg={colors.dim}>No more notifications</text>
153
+ </box>
154
+ ) : null}
155
+ </scrollbox>
156
+ );
157
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * PostCard - Individual post display component
3
+ */
4
+
5
+ import type { TweetData } from "@/api/types";
6
+
7
+ import { colors } from "@/lib/colors";
8
+ import { formatCount, formatRelativeTime, truncateText } from "@/lib/format";
9
+
10
+ import { QuotedPostCard } from "./QuotedPostCard";
11
+ import { ReplyPreviewCard } from "./ReplyPreviewCard";
12
+
13
+ const MAX_TEXT_LINES = 3;
14
+
15
+ /**
16
+ * Strip leading @mention from reply text if it matches the parent author.
17
+ * This removes redundant mentions when viewing replies in a thread context.
18
+ *
19
+ * Only strips if:
20
+ * - The mention is at the very beginning of the text
21
+ * - It's a single mention (not @user1 @user2 text)
22
+ */
23
+ function stripLeadingMention(text: string, username: string): string {
24
+ // Match @username at start, followed by optional whitespace
25
+ // But NOT followed by another @mention (to preserve multi-mention replies)
26
+ const pattern = new RegExp(`^@${username}\\s+(?!@)`, "i");
27
+ return text.replace(pattern, "");
28
+ }
29
+
30
+ interface PostCardProps {
31
+ post: TweetData;
32
+ isSelected: boolean;
33
+ id?: string;
34
+ /** Whether the tweet is liked by the current user */
35
+ isLiked?: boolean;
36
+ /** Whether the tweet is bookmarked by the current user */
37
+ isBookmarked?: boolean;
38
+ /** True briefly after liking (for visual pulse feedback) */
39
+ isJustLiked?: boolean;
40
+ /** True briefly after bookmarking (for visual pulse feedback) */
41
+ isJustBookmarked?: boolean;
42
+ /** Parent post author username - if provided, strips leading @mention matching this user */
43
+ parentAuthorUsername?: string;
44
+ /** Main post author username (for nested reply mention stripping) */
45
+ mainPostAuthorUsername?: string;
46
+ }
47
+
48
+ // Unicode symbols for like/bookmark states
49
+ const HEART_EMPTY = "\u2661"; // ♡
50
+ const HEART_FILLED = "\u2665"; // ♥
51
+ const FLAG_EMPTY = "\u2690"; // ⚐
52
+ const FLAG_FILLED = "\u2691"; // ⚑
53
+
54
+ export function PostCard({
55
+ post,
56
+ isSelected,
57
+ id,
58
+ isLiked,
59
+ isBookmarked,
60
+ isJustLiked,
61
+ isJustBookmarked,
62
+ parentAuthorUsername,
63
+ mainPostAuthorUsername,
64
+ }: PostCardProps) {
65
+ const textToDisplay = parentAuthorUsername
66
+ ? stripLeadingMention(post.text, parentAuthorUsername)
67
+ : post.text;
68
+ const displayText = truncateText(textToDisplay, MAX_TEXT_LINES);
69
+ const timeAgo = formatRelativeTime(post.createdAt);
70
+ const hasMedia = post.media && post.media.length > 0;
71
+
72
+ return (
73
+ <box
74
+ id={id}
75
+ style={{
76
+ flexDirection: "column",
77
+ marginBottom: 1,
78
+ paddingLeft: 1,
79
+ paddingRight: 1,
80
+ paddingTop: 1,
81
+ backgroundColor: isSelected ? colors.selectedBg : undefined,
82
+ }}
83
+ >
84
+ {/* Author line with selection indicator */}
85
+ <box style={{ flexDirection: "row" }}>
86
+ <text fg={colors.primary}>{isSelected ? "> " : " "}</text>
87
+ <text>
88
+ <b fg={colors.primary}>{post.author.name}</b>
89
+ </text>
90
+ <text fg={colors.handle}> @{post.author.username}</text>
91
+ <text fg={colors.dim}>{timeAgo ? ` · ${timeAgo}` : ""}</text>
92
+ </box>
93
+
94
+ {/* Post text */}
95
+ <box style={{ marginTop: 1, paddingLeft: 2 }}>
96
+ <text fg="#ffffff">{displayText}</text>
97
+ </box>
98
+
99
+ {/* Quoted tweet (if present) */}
100
+ {post.quotedTweet ? (
101
+ <box style={{ paddingLeft: 2 }}>
102
+ <QuotedPostCard post={post.quotedTweet} />
103
+ </box>
104
+ ) : null}
105
+
106
+ {/* Stats line with action indicators */}
107
+ <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2 }}>
108
+ <text fg={colors.muted}>
109
+ {formatCount(post.replyCount)} replies {" "}
110
+ {formatCount(post.retweetCount)} reposts {" "}
111
+ {formatCount(post.likeCount)} likes
112
+ </text>
113
+ {/* Like indicator - always visible, filled/empty based on state */}
114
+ <text
115
+ fg={
116
+ isJustLiked
117
+ ? colors.success // Bright green flash
118
+ : isLiked
119
+ ? colors.error // Red when liked
120
+ : colors.muted // Muted when not liked (more visible than dim)
121
+ }
122
+ >
123
+ {" "}
124
+ {isLiked ? HEART_FILLED : <b>{HEART_EMPTY}</b>}
125
+ </text>
126
+ {/* Bookmark indicator - always visible, filled/empty based on state */}
127
+ <text
128
+ fg={
129
+ isJustBookmarked
130
+ ? colors.success // Bright green flash
131
+ : isBookmarked
132
+ ? colors.primary // Blue when bookmarked
133
+ : colors.muted // Muted when not bookmarked (more visible than dim)
134
+ }
135
+ >
136
+ {" "}
137
+ {isBookmarked ? FLAG_FILLED : <b>{FLAG_EMPTY}</b>}
138
+ </text>
139
+ </box>
140
+
141
+ {/* Media indicators - colored labels */}
142
+ {hasMedia && (
143
+ <box style={{ flexDirection: "row", marginTop: 1, paddingLeft: 2 }}>
144
+ {post.media?.map((item) => {
145
+ const dims =
146
+ item.width && item.height
147
+ ? ` (${item.width}x${item.height})`
148
+ : "";
149
+ const typeLabel =
150
+ item.type === "photo"
151
+ ? "Image"
152
+ : item.type === "video"
153
+ ? "Video"
154
+ : "GIF";
155
+ const typeColor =
156
+ item.type === "photo"
157
+ ? colors.warning
158
+ : item.type === "video"
159
+ ? colors.warning
160
+ : colors.success;
161
+ return (
162
+ <text key={item.id} fg={typeColor}>
163
+ • {typeLabel}
164
+ {dims}
165
+ {" "}
166
+ </text>
167
+ );
168
+ })}
169
+ </box>
170
+ )}
171
+
172
+ {/* Nested reply preview (if present) */}
173
+ {post.nestedReplyPreview && (
174
+ <box style={{ paddingLeft: 2 }}>
175
+ <ReplyPreviewCard
176
+ reply={post.nestedReplyPreview}
177
+ stripMentions={[
178
+ post.author.username,
179
+ ...(mainPostAuthorUsername ? [mainPostAuthorUsername] : []),
180
+ ]}
181
+ />
182
+ </box>
183
+ )}
184
+ </box>
185
+ );
186
+ }