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,340 @@
1
+ /**
2
+ * Unit tests for useNavigation hook
3
+ * Tests navigation state transitions and history management
4
+ */
5
+
6
+ import { describe, expect, it, beforeEach } from "bun:test";
7
+
8
+ // Simple hook testing harness that simulates React's useState behavior
9
+ function createHookRunner<V extends string>(
10
+ initialView: V,
11
+ mainViews: readonly V[]
12
+ ) {
13
+ let history: V[] = [initialView];
14
+
15
+ const getState = () => {
16
+ const currentView = history[history.length - 1]!;
17
+ const previousView =
18
+ history.length > 1 ? history[history.length - 2]! : null;
19
+ const canGoBack = history.length > 1;
20
+ const isMainView = (mainViews as readonly string[]).includes(currentView);
21
+
22
+ return {
23
+ currentView,
24
+ previousView,
25
+ history: history as readonly V[],
26
+ canGoBack,
27
+ isMainView,
28
+ };
29
+ };
30
+
31
+ const navigate = (view: V) => {
32
+ history = [...history, view];
33
+ };
34
+
35
+ const goBack = (): boolean => {
36
+ if (history.length <= 1) return false;
37
+ history = history.slice(0, -1);
38
+ return true;
39
+ };
40
+
41
+ const cycleNext = () => {
42
+ const current = history[history.length - 1]!;
43
+ if (!(mainViews as readonly string[]).includes(current)) {
44
+ return; // Don't cycle if not on a main view
45
+ }
46
+
47
+ const currentIndex = (mainViews as readonly string[]).indexOf(current);
48
+ const nextIndex = (currentIndex + 1) % mainViews.length;
49
+ const nextView = mainViews[nextIndex]!;
50
+
51
+ history = [...history.slice(0, -1), nextView];
52
+ };
53
+
54
+ const reset = () => {
55
+ history = [initialView];
56
+ };
57
+
58
+ return {
59
+ getState,
60
+ navigate,
61
+ goBack,
62
+ cycleNext,
63
+ reset,
64
+ };
65
+ }
66
+
67
+ type View = "timeline" | "bookmarks" | "post-detail" | "profile";
68
+ const MAIN_VIEWS: readonly View[] = ["timeline", "bookmarks"];
69
+
70
+ describe("useNavigation", () => {
71
+ let nav: ReturnType<typeof createHookRunner<View>>;
72
+
73
+ beforeEach(() => {
74
+ nav = createHookRunner<View>("timeline", MAIN_VIEWS);
75
+ });
76
+
77
+ describe("initial state", () => {
78
+ it("starts with initialView as currentView", () => {
79
+ expect(nav.getState().currentView).toBe("timeline");
80
+ });
81
+
82
+ it("has null previousView at initial state", () => {
83
+ expect(nav.getState().previousView).toBeNull();
84
+ });
85
+
86
+ it("has canGoBack as false at initial state", () => {
87
+ expect(nav.getState().canGoBack).toBe(false);
88
+ });
89
+
90
+ it("has history with single item at initial state", () => {
91
+ expect(nav.getState().history).toEqual(["timeline"]);
92
+ });
93
+
94
+ it("correctly identifies main view", () => {
95
+ expect(nav.getState().isMainView).toBe(true);
96
+ });
97
+ });
98
+
99
+ describe("navigate()", () => {
100
+ it("pushes view to history", () => {
101
+ nav.navigate("post-detail");
102
+ expect(nav.getState().history).toEqual(["timeline", "post-detail"]);
103
+ });
104
+
105
+ it("updates currentView", () => {
106
+ nav.navigate("post-detail");
107
+ expect(nav.getState().currentView).toBe("post-detail");
108
+ });
109
+
110
+ it("updates previousView", () => {
111
+ nav.navigate("post-detail");
112
+ expect(nav.getState().previousView).toBe("timeline");
113
+ });
114
+
115
+ it("sets canGoBack to true", () => {
116
+ nav.navigate("post-detail");
117
+ expect(nav.getState().canGoBack).toBe(true);
118
+ });
119
+
120
+ it("correctly identifies non-main view", () => {
121
+ nav.navigate("post-detail");
122
+ expect(nav.getState().isMainView).toBe(false);
123
+ });
124
+
125
+ it("handles multiple navigations", () => {
126
+ nav.navigate("post-detail");
127
+ nav.navigate("profile");
128
+ expect(nav.getState().history).toEqual([
129
+ "timeline",
130
+ "post-detail",
131
+ "profile",
132
+ ]);
133
+ expect(nav.getState().currentView).toBe("profile");
134
+ expect(nav.getState().previousView).toBe("post-detail");
135
+ });
136
+ });
137
+
138
+ describe("goBack()", () => {
139
+ it("returns false when at initial state", () => {
140
+ const result = nav.goBack();
141
+ expect(result).toBe(false);
142
+ });
143
+
144
+ it("does not modify history when at initial state", () => {
145
+ nav.goBack();
146
+ expect(nav.getState().history).toEqual(["timeline"]);
147
+ });
148
+
149
+ it("returns true when back is possible", () => {
150
+ nav.navigate("post-detail");
151
+ const result = nav.goBack();
152
+ expect(result).toBe(true);
153
+ });
154
+
155
+ it("pops from history", () => {
156
+ nav.navigate("post-detail");
157
+ nav.goBack();
158
+ expect(nav.getState().history).toEqual(["timeline"]);
159
+ });
160
+
161
+ it("updates currentView to previous", () => {
162
+ nav.navigate("post-detail");
163
+ nav.goBack();
164
+ expect(nav.getState().currentView).toBe("timeline");
165
+ });
166
+
167
+ it("sets canGoBack to false when back to initial", () => {
168
+ nav.navigate("post-detail");
169
+ nav.goBack();
170
+ expect(nav.getState().canGoBack).toBe(false);
171
+ });
172
+
173
+ it("handles multi-level back navigation", () => {
174
+ nav.navigate("post-detail");
175
+ nav.navigate("profile");
176
+ nav.navigate("post-detail"); // View another post from profile
177
+
178
+ expect(nav.getState().currentView).toBe("post-detail");
179
+ expect(nav.goBack()).toBe(true);
180
+ expect(nav.getState().currentView).toBe("profile");
181
+ expect(nav.goBack()).toBe(true);
182
+ expect(nav.getState().currentView).toBe("post-detail");
183
+ expect(nav.goBack()).toBe(true);
184
+ expect(nav.getState().currentView).toBe("timeline");
185
+ expect(nav.goBack()).toBe(false);
186
+ });
187
+ });
188
+
189
+ describe("cycleNext()", () => {
190
+ it("cycles to next main view when on main view", () => {
191
+ expect(nav.getState().currentView).toBe("timeline");
192
+ nav.cycleNext();
193
+ expect(nav.getState().currentView).toBe("bookmarks");
194
+ });
195
+
196
+ it("wraps around to first main view", () => {
197
+ nav.cycleNext(); // timeline -> bookmarks
198
+ nav.cycleNext(); // bookmarks -> timeline
199
+ expect(nav.getState().currentView).toBe("timeline");
200
+ });
201
+
202
+ it("does not push to history (replaces)", () => {
203
+ nav.cycleNext();
204
+ expect(nav.getState().history).toEqual(["bookmarks"]);
205
+ expect(nav.getState().canGoBack).toBe(false);
206
+ });
207
+
208
+ it("does nothing when not on a main view", () => {
209
+ nav.navigate("post-detail");
210
+ const historyBefore = [...nav.getState().history];
211
+ nav.cycleNext();
212
+ expect(nav.getState().history).toEqual(historyBefore);
213
+ expect(nav.getState().currentView).toBe("post-detail");
214
+ });
215
+
216
+ it("replaces current main view in history stack", () => {
217
+ nav.navigate("post-detail");
218
+ nav.goBack(); // back to timeline
219
+ nav.cycleNext(); // timeline -> bookmarks
220
+ expect(nav.getState().currentView).toBe("bookmarks");
221
+ expect(nav.getState().history).toEqual(["bookmarks"]);
222
+ });
223
+ });
224
+
225
+ describe("complex navigation flows", () => {
226
+ it("timeline -> post-detail -> profile -> back -> back -> timeline", () => {
227
+ // Start at timeline
228
+ expect(nav.getState().currentView).toBe("timeline");
229
+
230
+ // Open a post
231
+ nav.navigate("post-detail");
232
+ expect(nav.getState().currentView).toBe("post-detail");
233
+ expect(nav.getState().previousView).toBe("timeline");
234
+
235
+ // Open profile from post
236
+ nav.navigate("profile");
237
+ expect(nav.getState().currentView).toBe("profile");
238
+ expect(nav.getState().previousView).toBe("post-detail");
239
+
240
+ // Go back to post-detail
241
+ expect(nav.goBack()).toBe(true);
242
+ expect(nav.getState().currentView).toBe("post-detail");
243
+ expect(nav.getState().previousView).toBe("timeline");
244
+
245
+ // Go back to timeline
246
+ expect(nav.goBack()).toBe(true);
247
+ expect(nav.getState().currentView).toBe("timeline");
248
+ expect(nav.getState().previousView).toBeNull();
249
+ });
250
+
251
+ it("tab switching: timeline -> bookmarks -> timeline", () => {
252
+ expect(nav.getState().currentView).toBe("timeline");
253
+ nav.cycleNext();
254
+ expect(nav.getState().currentView).toBe("bookmarks");
255
+ nav.cycleNext();
256
+ expect(nav.getState().currentView).toBe("timeline");
257
+ });
258
+
259
+ it("mixed: timeline -> tab -> bookmarks -> post-detail -> back -> bookmarks", () => {
260
+ // Start at timeline, tab to bookmarks
261
+ nav.cycleNext();
262
+ expect(nav.getState().currentView).toBe("bookmarks");
263
+
264
+ // Open post from bookmarks
265
+ nav.navigate("post-detail");
266
+ expect(nav.getState().currentView).toBe("post-detail");
267
+ expect(nav.getState().previousView).toBe("bookmarks");
268
+
269
+ // Back to bookmarks
270
+ nav.goBack();
271
+ expect(nav.getState().currentView).toBe("bookmarks");
272
+ });
273
+
274
+ it("profile -> post-detail -> back -> profile", () => {
275
+ // Navigate to profile (simulate opening from post-detail)
276
+ nav.navigate("post-detail");
277
+ nav.navigate("profile");
278
+ expect(nav.getState().currentView).toBe("profile");
279
+
280
+ // View a post from profile
281
+ nav.navigate("post-detail");
282
+ expect(nav.getState().currentView).toBe("post-detail");
283
+ expect(nav.getState().previousView).toBe("profile");
284
+
285
+ // Back to profile
286
+ nav.goBack();
287
+ expect(nav.getState().currentView).toBe("profile");
288
+ });
289
+ });
290
+
291
+ describe("isMainView", () => {
292
+ it("returns true for timeline", () => {
293
+ expect(nav.getState().isMainView).toBe(true);
294
+ });
295
+
296
+ it("returns true for bookmarks", () => {
297
+ nav.cycleNext();
298
+ expect(nav.getState().isMainView).toBe(true);
299
+ });
300
+
301
+ it("returns false for post-detail", () => {
302
+ nav.navigate("post-detail");
303
+ expect(nav.getState().isMainView).toBe(false);
304
+ });
305
+
306
+ it("returns false for profile", () => {
307
+ nav.navigate("profile");
308
+ expect(nav.getState().isMainView).toBe(false);
309
+ });
310
+ });
311
+
312
+ describe("edge cases", () => {
313
+ it("handles navigating to same view", () => {
314
+ nav.navigate("timeline"); // Navigate to current view
315
+ expect(nav.getState().history).toEqual(["timeline", "timeline"]);
316
+ expect(nav.getState().canGoBack).toBe(true);
317
+ });
318
+
319
+ it("handles rapid back navigation", () => {
320
+ nav.navigate("post-detail");
321
+ nav.navigate("profile");
322
+ nav.navigate("post-detail");
323
+
324
+ // Rapid back calls
325
+ expect(nav.goBack()).toBe(true);
326
+ expect(nav.goBack()).toBe(true);
327
+ expect(nav.goBack()).toBe(true);
328
+ expect(nav.goBack()).toBe(false); // Can't go back further
329
+ expect(nav.goBack()).toBe(false); // Still can't
330
+
331
+ expect(nav.getState().currentView).toBe("timeline");
332
+ });
333
+
334
+ it("handles cycleNext when mainViews has single item", () => {
335
+ const singleNav = createHookRunner<"home">("home", ["home"] as const);
336
+ singleNav.cycleNext();
337
+ expect(singleNav.getState().currentView).toBe("home");
338
+ });
339
+ });
340
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Navigation hook with history stack support
3
+ * Provides view navigation, back navigation, and tab cycling for main views
4
+ */
5
+
6
+ import { useState, useCallback } from "react";
7
+
8
+ export interface UseNavigationOptions<V extends string> {
9
+ /** Initial view to display */
10
+ initialView: V;
11
+ /** Main views that can be cycled with Tab (excludes overlay views) */
12
+ mainViews: readonly V[];
13
+ }
14
+
15
+ export interface UseNavigationResult<V extends string> {
16
+ /** Currently active view */
17
+ currentView: V;
18
+ /** Previous view in history (null if at initial) */
19
+ previousView: V | null;
20
+ /** Full navigation history (readonly) */
21
+ history: readonly V[];
22
+
23
+ /** Navigate to a new view (pushes to history stack) */
24
+ navigate: (view: V) => void;
25
+ /** Go back to previous view (returns true if successful, false if at initial) */
26
+ goBack: () => boolean;
27
+ /** Cycle to next main view (replaces current view, only works when on a main view) */
28
+ cycleNext: () => void;
29
+
30
+ /** Whether back navigation is possible */
31
+ canGoBack: boolean;
32
+ /** Whether current view is one of the main views */
33
+ isMainView: boolean;
34
+ }
35
+
36
+ /**
37
+ * Hook for managing navigation state with history support
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * const { currentView, navigate, goBack, cycleNext } = useNavigation({
42
+ * initialView: "timeline",
43
+ * mainViews: ["timeline", "bookmarks"],
44
+ * });
45
+ *
46
+ * // Navigate to detail view
47
+ * navigate("post-detail");
48
+ *
49
+ * // Go back
50
+ * goBack();
51
+ *
52
+ * // Cycle between main views (Tab key)
53
+ * cycleNext();
54
+ * ```
55
+ */
56
+ export function useNavigation<V extends string>(
57
+ options: UseNavigationOptions<V>
58
+ ): UseNavigationResult<V> {
59
+ const { initialView, mainViews } = options;
60
+ const [history, setHistory] = useState<V[]>([initialView]);
61
+
62
+ const currentView = history[history.length - 1]!;
63
+ const previousView = history.length > 1 ? history[history.length - 2]! : null;
64
+ const canGoBack = history.length > 1;
65
+ const isMainView = (mainViews as readonly string[]).includes(currentView);
66
+
67
+ const navigate = useCallback((view: V) => {
68
+ setHistory((prev) => [...prev, view]);
69
+ }, []);
70
+
71
+ const goBack = useCallback((): boolean => {
72
+ if (history.length <= 1) return false;
73
+ setHistory((prev) => prev.slice(0, -1));
74
+ return true;
75
+ }, [history.length]);
76
+
77
+ const cycleNext = useCallback(() => {
78
+ setHistory((prev) => {
79
+ const current = prev[prev.length - 1]!;
80
+ if (!(mainViews as readonly string[]).includes(current)) {
81
+ return prev; // Don't cycle if not on a main view
82
+ }
83
+
84
+ const currentIndex = (mainViews as readonly string[]).indexOf(current);
85
+ const nextIndex = (currentIndex + 1) % mainViews.length;
86
+ const nextView = mainViews[nextIndex]!;
87
+
88
+ // Replace current view (don't push to history)
89
+ return [...prev.slice(0, -1), nextView];
90
+ });
91
+ }, [mainViews]);
92
+
93
+ return {
94
+ currentView,
95
+ previousView,
96
+ history,
97
+ navigate,
98
+ goBack,
99
+ cycleNext,
100
+ canGoBack,
101
+ isMainView,
102
+ };
103
+ }