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,377 @@
1
+ /**
2
+ * Unit tests for useNotifications hook
3
+ * Tests notification fetching, unread count calculation, and error handling
4
+ */
5
+
6
+ import { describe, expect, it, mock } from "bun:test";
7
+
8
+ import type { XClient } from "@/api/client";
9
+ import type {
10
+ ApiError,
11
+ NotificationData,
12
+ NotificationsResult,
13
+ } from "@/api/types";
14
+
15
+ // Simple test harness for the hook logic
16
+ // We test the core logic directly rather than React integration
17
+ function createMockClient(
18
+ getNotificationsResult: NotificationsResult
19
+ ): XClient {
20
+ return {
21
+ getNotifications: mock(() => Promise.resolve(getNotificationsResult)),
22
+ } as unknown as XClient;
23
+ }
24
+
25
+ function createNotification(
26
+ overrides: Partial<NotificationData> = {}
27
+ ): NotificationData {
28
+ return {
29
+ id: overrides.id ?? "notif-1",
30
+ icon: overrides.icon ?? "heart_icon",
31
+ message: overrides.message ?? "Test notification",
32
+ url: overrides.url ?? "https://x.com/notification",
33
+ timestamp: overrides.timestamp ?? "2024-01-01T00:00:00Z",
34
+ sortIndex: overrides.sortIndex ?? "1700000000000",
35
+ targetTweet: overrides.targetTweet,
36
+ fromUsers: overrides.fromUsers,
37
+ };
38
+ }
39
+
40
+ describe("useNotifications", () => {
41
+ describe("initial fetch", () => {
42
+ it("returns notifications from successful API response", async () => {
43
+ const notifications = [
44
+ createNotification({ id: "1", sortIndex: "3" }),
45
+ createNotification({ id: "2", sortIndex: "2" }),
46
+ createNotification({ id: "3", sortIndex: "1" }),
47
+ ];
48
+
49
+ const client = createMockClient({
50
+ success: true,
51
+ notifications,
52
+ });
53
+
54
+ const result = await client.getNotifications();
55
+ expect(result.success).toBe(true);
56
+ if (result.success) {
57
+ expect(result.notifications).toEqual(notifications);
58
+ }
59
+ });
60
+
61
+ it("returns error message from failed API response", async () => {
62
+ const client = createMockClient({
63
+ success: false,
64
+ error: {
65
+ type: "network_error",
66
+ message: "Network timeout",
67
+ },
68
+ });
69
+
70
+ const result = await client.getNotifications();
71
+ expect(result.success).toBe(false);
72
+ if (!result.success) {
73
+ expect(result.error.message).toBe("Network timeout");
74
+ }
75
+ });
76
+ });
77
+
78
+ describe("unread count calculation", () => {
79
+ it("calculates unread count based on sort index comparison", () => {
80
+ const notifications = [
81
+ createNotification({ id: "1", sortIndex: "1700000000003" }), // unread
82
+ createNotification({ id: "2", sortIndex: "1700000000002" }), // unread
83
+ createNotification({ id: "3", sortIndex: "1700000000001" }), // read
84
+ ];
85
+ const unreadSortIndex = "1700000000001";
86
+
87
+ // Simulating the hook's unread count calculation
88
+ const unreadCount = notifications.filter(
89
+ (n) => n.sortIndex > unreadSortIndex
90
+ ).length;
91
+
92
+ expect(unreadCount).toBe(2);
93
+ });
94
+
95
+ it("returns 0 unread when no unreadSortIndex provided", () => {
96
+ const notifications = [
97
+ createNotification({ id: "1", sortIndex: "1700000000003" }),
98
+ createNotification({ id: "2", sortIndex: "1700000000002" }),
99
+ ];
100
+
101
+ // Simulating the hook's unread count calculation when no unreadSortIndex
102
+ function calculateUnreadCount(
103
+ notifs: NotificationData[],
104
+ unreadIdx: string | undefined
105
+ ): number {
106
+ if (!unreadIdx) return 0;
107
+ return notifs.filter((n) => n.sortIndex > unreadIdx).length;
108
+ }
109
+
110
+ const unreadCount = calculateUnreadCount(notifications, undefined);
111
+ expect(unreadCount).toBe(0);
112
+ });
113
+
114
+ it("returns all as unread when all sort indices are greater", () => {
115
+ const notifications = [
116
+ createNotification({ id: "1", sortIndex: "1700000000003" }),
117
+ createNotification({ id: "2", sortIndex: "1700000000002" }),
118
+ createNotification({ id: "3", sortIndex: "1700000000001" }),
119
+ ];
120
+ const unreadSortIndex = "1700000000000";
121
+
122
+ const unreadCount = notifications.filter(
123
+ (n) => n.sortIndex > unreadSortIndex
124
+ ).length;
125
+
126
+ expect(unreadCount).toBe(3);
127
+ });
128
+
129
+ it("returns 0 unread when all sort indices are less than or equal", () => {
130
+ const notifications = [
131
+ createNotification({ id: "1", sortIndex: "1700000000001" }),
132
+ createNotification({ id: "2", sortIndex: "1700000000002" }),
133
+ ];
134
+ const unreadSortIndex = "1700000000003";
135
+
136
+ const unreadCount = notifications.filter(
137
+ (n) => n.sortIndex > unreadSortIndex
138
+ ).length;
139
+
140
+ expect(unreadCount).toBe(0);
141
+ });
142
+ });
143
+
144
+ describe("error handling", () => {
145
+ it("extracts error message from ApiError", () => {
146
+ const apiError: ApiError = {
147
+ type: "rate_limit",
148
+ message: "Too many requests",
149
+ retryAfter: 60,
150
+ };
151
+
152
+ expect(apiError.message).toBe("Too many requests");
153
+ expect(apiError.retryAfter).toBe(60);
154
+ });
155
+
156
+ it("identifies rate limit errors", () => {
157
+ const apiError: ApiError = {
158
+ type: "rate_limit",
159
+ message: "Rate limited",
160
+ retryAfter: 120,
161
+ };
162
+
163
+ expect(apiError.type).toBe("rate_limit");
164
+ expect(apiError.retryAfter).toBe(120);
165
+ });
166
+
167
+ it("identifies auth errors", () => {
168
+ const apiError: ApiError = {
169
+ type: "auth_expired",
170
+ message: "Authentication required",
171
+ };
172
+
173
+ expect(apiError.type).toBe("auth_expired");
174
+ });
175
+
176
+ it("identifies network errors", () => {
177
+ const apiError: ApiError = {
178
+ type: "network_error",
179
+ message: "Connection refused",
180
+ };
181
+
182
+ expect(apiError.type).toBe("network_error");
183
+ });
184
+ });
185
+
186
+ describe("rate limit countdown", () => {
187
+ it("blocks retry when countdown is active", () => {
188
+ const retryCountdown = 30;
189
+ const retryBlocked = retryCountdown > 0;
190
+
191
+ expect(retryBlocked).toBe(true);
192
+ });
193
+
194
+ it("allows retry when countdown is zero", () => {
195
+ const retryCountdown = 0;
196
+ const retryBlocked = retryCountdown > 0;
197
+
198
+ expect(retryBlocked).toBe(false);
199
+ });
200
+
201
+ it("countdown decrements each second", () => {
202
+ let countdown = 5;
203
+
204
+ // Simulate countdown
205
+ countdown = countdown - 1;
206
+ expect(countdown).toBe(4);
207
+
208
+ countdown = countdown - 1;
209
+ expect(countdown).toBe(3);
210
+
211
+ countdown = countdown - 1;
212
+ expect(countdown).toBe(2);
213
+ });
214
+
215
+ it("countdown stops at zero", () => {
216
+ let countdown = 1;
217
+
218
+ // Simulate countdown reaching zero
219
+ countdown = Math.max(0, countdown - 1);
220
+ expect(countdown).toBe(0);
221
+
222
+ // Should not go negative
223
+ countdown = Math.max(0, countdown - 1);
224
+ expect(countdown).toBe(0);
225
+ });
226
+ });
227
+
228
+ describe("refresh behavior", () => {
229
+ it("refresh is blocked during countdown", () => {
230
+ const retryCountdown: number = 10;
231
+ const canRefresh = retryCountdown <= 0;
232
+
233
+ expect(canRefresh).toBe(false);
234
+ });
235
+
236
+ it("refresh is allowed when countdown is zero", () => {
237
+ const retryCountdown: number = 0;
238
+ const canRefresh = retryCountdown <= 0;
239
+
240
+ expect(canRefresh).toBe(true);
241
+ });
242
+ });
243
+
244
+ describe("notification data parsing", () => {
245
+ it("handles notification with target tweet", () => {
246
+ const notification = createNotification({
247
+ icon: "heart_icon",
248
+ message: "User liked your post",
249
+ targetTweet: {
250
+ id: "tweet-123",
251
+ text: "This is my tweet",
252
+ author: { username: "testuser", name: "Test User" },
253
+ },
254
+ });
255
+
256
+ expect(notification.targetTweet).toBeDefined();
257
+ expect(notification.targetTweet?.id).toBe("tweet-123");
258
+ expect(notification.targetTweet?.text).toBe("This is my tweet");
259
+ });
260
+
261
+ it("handles notification with fromUsers", () => {
262
+ const notification = createNotification({
263
+ icon: "person_icon",
264
+ message: "New follower",
265
+ fromUsers: [
266
+ { id: "user-123", username: "newfollower", name: "New Follower" },
267
+ ],
268
+ });
269
+
270
+ expect(notification.fromUsers).toBeDefined();
271
+ expect(notification.fromUsers?.length).toBe(1);
272
+ expect(notification.fromUsers?.[0]?.username).toBe("newfollower");
273
+ });
274
+
275
+ it("handles notification without target tweet or fromUsers", () => {
276
+ const notification = createNotification({
277
+ icon: "bird_icon",
278
+ message: "System notification",
279
+ });
280
+
281
+ expect(notification.targetTweet).toBeUndefined();
282
+ expect(notification.fromUsers).toBeUndefined();
283
+ });
284
+ });
285
+
286
+ describe("notification icons", () => {
287
+ it("supports heart_icon for likes", () => {
288
+ const notification = createNotification({ icon: "heart_icon" });
289
+ expect(notification.icon).toBe("heart_icon");
290
+ });
291
+
292
+ it("supports person_icon for follows", () => {
293
+ const notification = createNotification({ icon: "person_icon" });
294
+ expect(notification.icon).toBe("person_icon");
295
+ });
296
+
297
+ it("supports bird_icon for system notifications", () => {
298
+ const notification = createNotification({ icon: "bird_icon" });
299
+ expect(notification.icon).toBe("bird_icon");
300
+ });
301
+
302
+ it("supports retweet_icon for retweets", () => {
303
+ const notification = createNotification({ icon: "retweet_icon" });
304
+ expect(notification.icon).toBe("retweet_icon");
305
+ });
306
+
307
+ it("supports reply_icon for replies", () => {
308
+ const notification = createNotification({ icon: "reply_icon" });
309
+ expect(notification.icon).toBe("reply_icon");
310
+ });
311
+ });
312
+
313
+ describe("empty states", () => {
314
+ it("handles empty notifications array", async () => {
315
+ const client = createMockClient({
316
+ success: true,
317
+ notifications: [],
318
+ });
319
+
320
+ const result = await client.getNotifications();
321
+ expect(result.success).toBe(true);
322
+ if (result.success) {
323
+ expect(result.notifications.length).toBe(0);
324
+ }
325
+ });
326
+
327
+ it("unread count is 0 for empty notifications", () => {
328
+ const notifications: NotificationData[] = [];
329
+ const unreadSortIndex = "1700000000000";
330
+
331
+ const unreadCount = notifications.filter(
332
+ (n) => n.sortIndex > unreadSortIndex
333
+ ).length;
334
+
335
+ expect(unreadCount).toBe(0);
336
+ });
337
+ });
338
+
339
+ describe("API result types", () => {
340
+ it("success result contains notifications", async () => {
341
+ const notifications = [createNotification()];
342
+ const client = createMockClient({
343
+ success: true,
344
+ notifications,
345
+ unreadSortIndex: "1699999999999",
346
+ topCursor: "TOP",
347
+ bottomCursor: "BOTTOM",
348
+ });
349
+
350
+ const result = await client.getNotifications();
351
+ expect(result.success).toBe(true);
352
+ if (result.success) {
353
+ expect(result.notifications).toEqual(notifications);
354
+ expect(result.unreadSortIndex).toBe("1699999999999");
355
+ expect(result.topCursor).toBe("TOP");
356
+ expect(result.bottomCursor).toBe("BOTTOM");
357
+ }
358
+ });
359
+
360
+ it("failure result contains error", async () => {
361
+ const client = createMockClient({
362
+ success: false,
363
+ error: {
364
+ type: "unknown",
365
+ message: "Something went wrong",
366
+ },
367
+ });
368
+
369
+ const result = await client.getNotifications();
370
+ expect(result.success).toBe(false);
371
+ if (!result.success) {
372
+ expect(result.error.type).toBe("unknown");
373
+ expect(result.error.message).toBe("Something went wrong");
374
+ }
375
+ });
376
+ });
377
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * useNotifications - Hook for fetching and managing notification data
3
+ * Includes unread count tracking and typed error handling
4
+ */
5
+
6
+ import { useCallback, useRef, useState } from "react";
7
+
8
+ import type { XClient } from "@/api/client";
9
+ import type { ApiError, NotificationData } from "@/api/types";
10
+
11
+ import type { PaginatedFetchResult } from "./usePaginatedData";
12
+
13
+ import { usePaginatedData } from "./usePaginatedData";
14
+
15
+ export interface UseNotificationsOptions {
16
+ client: XClient;
17
+ }
18
+
19
+ export interface UseNotificationsResult {
20
+ /** List of notifications */
21
+ notifications: NotificationData[];
22
+ /** Count of unread notifications */
23
+ unreadCount: number;
24
+ /** Whether data is currently loading */
25
+ loading: boolean;
26
+ /** Whether more notifications are being loaded (pagination) */
27
+ loadingMore: boolean;
28
+ /** Whether there are more notifications to load */
29
+ hasMore: boolean;
30
+ /** Error message if fetch failed */
31
+ error: string | null;
32
+ /** Typed error with rate limit info, auth status, etc. */
33
+ apiError: ApiError | null;
34
+ /** Refresh notifications */
35
+ refresh: () => void;
36
+ /** Load more notifications (pagination) */
37
+ loadMore: () => void;
38
+ /** Whether retry is currently blocked (e.g., rate limit countdown) */
39
+ retryBlocked: boolean;
40
+ /** Seconds until retry is allowed (for rate limit countdown) */
41
+ retryCountdown: number;
42
+ }
43
+
44
+ export function useNotifications({
45
+ client,
46
+ }: UseNotificationsOptions): UseNotificationsResult {
47
+ const [unreadCount, setUnreadCount] = useState(0);
48
+ // Store unreadSortIndex for calculating unread count
49
+ const unreadSortIndexRef = useRef<string | undefined>(undefined);
50
+
51
+ // Create fetch function that adapts client API to PaginatedFetchResult
52
+ const fetchFn = useCallback(
53
+ async (
54
+ cursor?: string
55
+ ): Promise<PaginatedFetchResult<NotificationData>> => {
56
+ const result = await client.getNotifications(30, cursor);
57
+
58
+ if (result.success) {
59
+ // On initial fetch, update unread sort index and calculate unread count
60
+ if (!cursor) {
61
+ unreadSortIndexRef.current = result.unreadSortIndex;
62
+ if (result.unreadSortIndex) {
63
+ const unread = result.notifications.filter(
64
+ (n) => n.sortIndex > result.unreadSortIndex!
65
+ ).length;
66
+ setUnreadCount(unread);
67
+ } else {
68
+ setUnreadCount(0);
69
+ }
70
+ }
71
+
72
+ return {
73
+ success: true,
74
+ items: result.notifications,
75
+ nextCursor: result.bottomCursor,
76
+ };
77
+ }
78
+ return { success: false, error: result.error };
79
+ },
80
+ [client]
81
+ );
82
+
83
+ const getId = useCallback(
84
+ (notification: NotificationData) => notification.id,
85
+ []
86
+ );
87
+
88
+ const {
89
+ data: notifications,
90
+ loading,
91
+ loadingMore,
92
+ hasMore,
93
+ error,
94
+ apiError,
95
+ refresh,
96
+ loadMore,
97
+ retryBlocked,
98
+ retryCountdown,
99
+ } = usePaginatedData({
100
+ fetchFn,
101
+ getId,
102
+ });
103
+
104
+ return {
105
+ notifications,
106
+ unreadCount,
107
+ loading,
108
+ loadingMore,
109
+ hasMore,
110
+ error,
111
+ apiError,
112
+ refresh,
113
+ loadMore,
114
+ retryBlocked,
115
+ retryCountdown,
116
+ };
117
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * usePaginatedData - Generic hook for paginated data fetching
3
+ * Handles loading states, error handling, rate limit countdowns,
4
+ * deduplication, and infinite scroll pagination
5
+ */
6
+
7
+ import { useCallback, useEffect, useRef, useState } from "react";
8
+
9
+ import type { ApiError } from "@/api/types";
10
+
11
+ /**
12
+ * Result type for paginated fetch operations
13
+ */
14
+ export type PaginatedFetchResult<T> =
15
+ | { success: true; items: T[]; nextCursor?: string }
16
+ | { success: false; error: ApiError };
17
+
18
+ /**
19
+ * Options for configuring the usePaginatedData hook
20
+ */
21
+ export interface PaginatedDataOptions<T> {
22
+ /** Function to fetch data, receives cursor for pagination */
23
+ fetchFn: (cursor?: string) => Promise<PaginatedFetchResult<T>>;
24
+ /** Extract unique ID from each item for deduplication */
25
+ getId: (item: T) => string;
26
+ /** Dependencies that trigger a full refetch when changed */
27
+ deps?: unknown[];
28
+ }
29
+
30
+ /**
31
+ * Result returned by the usePaginatedData hook
32
+ */
33
+ export interface PaginatedDataResult<T> {
34
+ /** List of fetched items */
35
+ data: T[];
36
+ /** Whether initial data is loading */
37
+ loading: boolean;
38
+ /** Whether more data is being loaded (pagination) */
39
+ loadingMore: boolean;
40
+ /** Whether there are more items to load */
41
+ hasMore: boolean;
42
+ /** Error message if fetch failed (legacy string for backwards compat) */
43
+ error: string | null;
44
+ /** Typed error with rate limit info, auth status, etc. */
45
+ apiError: ApiError | null;
46
+ /** Refresh data (resets to first page) */
47
+ refresh: () => void;
48
+ /** Load more items (pagination) */
49
+ loadMore: () => void;
50
+ /** Whether retry is currently blocked (e.g., rate limit countdown) */
51
+ retryBlocked: boolean;
52
+ /** Seconds until retry is allowed (for rate limit countdown) */
53
+ retryCountdown: number;
54
+ /** Reset all data and state (useful for tab switches) */
55
+ reset: () => void;
56
+ /** Remove an item by ID (useful for unbookmarking, etc.) */
57
+ removeItem: (id: string) => void;
58
+ }
59
+
60
+ export function usePaginatedData<T>({
61
+ fetchFn,
62
+ getId,
63
+ deps = [],
64
+ }: PaginatedDataOptions<T>): PaginatedDataResult<T> {
65
+ const [data, setData] = useState<T[]>([]);
66
+ const [loading, setLoading] = useState(true);
67
+ const [loadingMore, setLoadingMore] = useState(false);
68
+ const [error, setError] = useState<string | null>(null);
69
+ const [apiError, setApiError] = useState<ApiError | null>(null);
70
+ const [refreshCounter, setRefreshCounter] = useState(0);
71
+ const [retryCountdown, setRetryCountdown] = useState(0);
72
+ const [nextCursor, setNextCursor] = useState<string | undefined>();
73
+ const [hasMore, setHasMore] = useState(true);
74
+ const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
75
+
76
+ // Track seen IDs to deduplicate items across pages
77
+ const seenIds = useRef(new Set<string>());
78
+
79
+ // Clear countdown timer helper
80
+ const clearCountdown = useCallback(() => {
81
+ if (countdownRef.current) {
82
+ clearInterval(countdownRef.current);
83
+ countdownRef.current = null;
84
+ }
85
+ }, []);
86
+
87
+ // Clear countdown timer on unmount
88
+ useEffect(() => {
89
+ return () => {
90
+ clearCountdown();
91
+ };
92
+ }, [clearCountdown]);
93
+
94
+ // Start rate limit countdown
95
+ const startCountdown = useCallback(
96
+ (seconds: number) => {
97
+ setRetryCountdown(seconds);
98
+ clearCountdown();
99
+
100
+ countdownRef.current = setInterval(() => {
101
+ setRetryCountdown((prev) => {
102
+ if (prev <= 1) {
103
+ clearCountdown();
104
+ return 0;
105
+ }
106
+ return prev - 1;
107
+ });
108
+ }, 1000);
109
+ },
110
+ [clearCountdown]
111
+ );
112
+
113
+ const fetchData = useCallback(async () => {
114
+ setLoading(true);
115
+ setError(null);
116
+ setApiError(null);
117
+ // Reset pagination state for fresh fetch
118
+ seenIds.current.clear();
119
+
120
+ const result = await fetchFn();
121
+
122
+ if (result.success) {
123
+ // Populate seen IDs with initial items
124
+ for (const item of result.items) {
125
+ seenIds.current.add(getId(item));
126
+ }
127
+ setData(result.items);
128
+ setNextCursor(result.nextCursor);
129
+ setHasMore(!!result.nextCursor && result.items.length > 0);
130
+ setRetryCountdown(0);
131
+ clearCountdown();
132
+ } else {
133
+ setError(result.error.message);
134
+ setApiError(result.error);
135
+ setHasMore(false);
136
+
137
+ // Start countdown for rate limits
138
+ if (result.error.type === "rate_limit" && result.error.retryAfter) {
139
+ startCountdown(result.error.retryAfter);
140
+ }
141
+ }
142
+
143
+ setLoading(false);
144
+ }, [fetchFn, getId, clearCountdown, startCountdown]);
145
+
146
+ // Fetch when dependencies change or refresh is triggered
147
+ useEffect(() => {
148
+ fetchData();
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps
150
+ }, [fetchData, refreshCounter, ...deps]);
151
+
152
+ const loadMore = useCallback(async () => {
153
+ if (!nextCursor || loadingMore || !hasMore) return;
154
+
155
+ setLoadingMore(true);
156
+
157
+ const result = await fetchFn(nextCursor);
158
+
159
+ if (result.success) {
160
+ // Filter out duplicates using seenIds
161
+ const newItems = result.items.filter(
162
+ (item) => !seenIds.current.has(getId(item))
163
+ );
164
+ for (const item of newItems) {
165
+ seenIds.current.add(getId(item));
166
+ }
167
+
168
+ setData((prev) => [...prev, ...newItems]);
169
+ setNextCursor(result.nextCursor);
170
+ setHasMore(!!result.nextCursor && result.items.length > 0);
171
+ } else {
172
+ // On error, don't clear existing data, just stop pagination
173
+ setHasMore(false);
174
+ }
175
+
176
+ setLoadingMore(false);
177
+ }, [fetchFn, getId, nextCursor, loadingMore, hasMore]);
178
+
179
+ const refresh = useCallback(() => {
180
+ // Don't allow refresh during rate limit countdown
181
+ if (retryCountdown > 0) return;
182
+ // Reset pagination state before refresh
183
+ setNextCursor(undefined);
184
+ setHasMore(true);
185
+ setRefreshCounter((prev) => prev + 1);
186
+ }, [retryCountdown]);
187
+
188
+ const reset = useCallback(() => {
189
+ setData([]);
190
+ setNextCursor(undefined);
191
+ setHasMore(true);
192
+ seenIds.current.clear();
193
+ }, []);
194
+
195
+ const removeItem = useCallback(
196
+ (id: string) => {
197
+ setData((prev) => prev.filter((item) => getId(item) !== id));
198
+ seenIds.current.delete(id);
199
+ },
200
+ [getId]
201
+ );
202
+
203
+ return {
204
+ data,
205
+ loading,
206
+ loadingMore,
207
+ hasMore,
208
+ error,
209
+ apiError,
210
+ refresh,
211
+ loadMore,
212
+ retryBlocked: retryCountdown > 0,
213
+ retryCountdown,
214
+ reset,
215
+ removeItem,
216
+ };
217
+ }