xfeed 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +5 -0
- package/package.json +43 -0
- package/src/api/actions.ts +16 -0
- package/src/api/client.test.ts +3370 -0
- package/src/api/client.ts +4319 -0
- package/src/api/query-ids.json +11 -0
- package/src/api/query-ids.test.ts +118 -0
- package/src/api/query-ids.ts +59 -0
- package/src/api/runtime-query-ids.test.ts +926 -0
- package/src/api/runtime-query-ids.ts +389 -0
- package/src/api/types.ts +581 -0
- package/src/app.tsx +664 -0
- package/src/auth/browser-detect.ts +150 -0
- package/src/auth/browser-picker.ts +118 -0
- package/src/auth/check.test.preload.ts +94 -0
- package/src/auth/check.test.ts +388 -0
- package/src/auth/check.ts +220 -0
- package/src/auth/cookies.test.ts +529 -0
- package/src/auth/cookies.ts +299 -0
- package/src/auth/manual-entry.ts +88 -0
- package/src/auth/session.ts +30 -0
- package/src/components/ErrorBanner.tsx +172 -0
- package/src/components/Footer.tsx +90 -0
- package/src/components/Header.tsx +57 -0
- package/src/components/NotificationItem.test.ts +252 -0
- package/src/components/NotificationItem.tsx +80 -0
- package/src/components/NotificationList.test.ts +328 -0
- package/src/components/NotificationList.tsx +157 -0
- package/src/components/PostCard.tsx +186 -0
- package/src/components/PostList.tsx +232 -0
- package/src/components/QuotedPostCard.tsx +55 -0
- package/src/components/ReplyPreviewCard.tsx +80 -0
- package/src/components/ThreadView.prototype.tsx +533 -0
- package/src/components/Toast.tsx +28 -0
- package/src/config/loader.ts +69 -0
- package/src/config/types.ts +27 -0
- package/src/contexts/ModalContext.tsx +227 -0
- package/src/experiments/TimelineScreenExperimental.tsx +202 -0
- package/src/experiments/index.tsx +43 -0
- package/src/experiments/query-client.ts +132 -0
- package/src/experiments/use-bookmark-mutation.ts +342 -0
- package/src/experiments/use-bookmarks-query.ts +166 -0
- package/src/experiments/use-notifications-query.ts +368 -0
- package/src/experiments/use-post-detail-query.ts +187 -0
- package/src/experiments/use-profile-query.ts +162 -0
- package/src/experiments/use-timeline-query.ts +201 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/useActions.ts +354 -0
- package/src/hooks/useBookmarkFolders.ts +70 -0
- package/src/hooks/useBookmarks.ts +111 -0
- package/src/hooks/useCountdown.ts +75 -0
- package/src/hooks/useListNavigation.test.ts +273 -0
- package/src/hooks/useListNavigation.ts +118 -0
- package/src/hooks/useNavigation.test.ts +340 -0
- package/src/hooks/useNavigation.ts +103 -0
- package/src/hooks/useNotifications.test.ts +377 -0
- package/src/hooks/useNotifications.ts +117 -0
- package/src/hooks/usePaginatedData.ts +217 -0
- package/src/hooks/usePostDetail.ts +137 -0
- package/src/hooks/useThread.prototype.ts +314 -0
- package/src/hooks/useTimeline.ts +136 -0
- package/src/hooks/useUserProfile.ts +142 -0
- package/src/index.tsx +304 -0
- package/src/lib/colors.ts +41 -0
- package/src/lib/format.ts +69 -0
- package/src/lib/media.ts +464 -0
- package/src/lib/result.ts +6 -0
- package/src/lib/text.tsx +76 -0
- package/src/modals/BookmarkFolderSelector.tsx +260 -0
- package/src/modals/ExitConfirmationModal.tsx +131 -0
- package/src/modals/FolderPicker.tsx +281 -0
- package/src/modals/README.md +171 -0
- package/src/modals/SessionExpiredModal.tsx +47 -0
- package/src/modals/index.ts +4 -0
- package/src/screens/.gitkeep +0 -0
- package/src/screens/BookmarksScreen.tsx +168 -0
- package/src/screens/NotificationsScreen.tsx +172 -0
- package/src/screens/PostDetailScreen.tsx +976 -0
- package/src/screens/ProfileScreen.tsx +528 -0
- package/src/screens/SplashScreen.tsx +72 -0
- package/src/screens/ThreadScreen.tsx +81 -0
- package/src/screens/TimelineScreen.tsx +188 -0
- package/vendor/sweet-cookie/LICENSE +22 -0
- package/vendor/sweet-cookie/README.md +29 -0
- package/vendor/sweet-cookie/dist/index.d.ts +3 -0
- package/vendor/sweet-cookie/dist/index.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/index.js +2 -0
- package/vendor/sweet-cookie/dist/index.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chrome.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chrome.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chrome.js +27 -0
- package/vendor/sweet-cookie/dist/providers/chrome.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts +11 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js +100 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/crypto.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts +25 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js +104 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/linuxKeyring.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js +293 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/shared.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js +26 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqlite/windowsDpapi.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js +51 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteLinux.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts +10 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js +118 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteMac.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js +38 -0
- package/vendor/sweet-cookie/dist/providers/chromeSqliteWindows.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts +5 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js +33 -0
- package/vendor/sweet-cookie/dist/providers/chromium/linuxPaths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts +24 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js +30 -0
- package/vendor/sweet-cookie/dist/providers/chromium/macosKeychain.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts +11 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.js +43 -0
- package/vendor/sweet-cookie/dist/providers/chromium/paths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js +41 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsMasterKey.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js +53 -0
- package/vendor/sweet-cookie/dist/providers/chromium/windowsPaths.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edge.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/edge.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edge.js +27 -0
- package/vendor/sweet-cookie/dist/providers/edge.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js +53 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteLinux.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js +60 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteMac.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts +7 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js +38 -0
- package/vendor/sweet-cookie/dist/providers/edgeSqliteWindows.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts +6 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js +257 -0
- package/vendor/sweet-cookie/dist/providers/firefoxSqlite.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/inline.d.ts +8 -0
- package/vendor/sweet-cookie/dist/providers/inline.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/inline.js +71 -0
- package/vendor/sweet-cookie/dist/providers/inline.js.map +1 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts +6 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js +173 -0
- package/vendor/sweet-cookie/dist/providers/safariBinaryCookies.js.map +1 -0
- package/vendor/sweet-cookie/dist/public.d.ts +26 -0
- package/vendor/sweet-cookie/dist/public.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/public.js +197 -0
- package/vendor/sweet-cookie/dist/public.js.map +1 -0
- package/vendor/sweet-cookie/dist/types.d.ts +127 -0
- package/vendor/sweet-cookie/dist/types.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/types.js +2 -0
- package/vendor/sweet-cookie/dist/types.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/base64.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/base64.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/base64.js +18 -0
- package/vendor/sweet-cookie/dist/util/base64.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/exec.d.ts +8 -0
- package/vendor/sweet-cookie/dist/util/exec.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/exec.js +110 -0
- package/vendor/sweet-cookie/dist/util/exec.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/expire.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/expire.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/expire.js +32 -0
- package/vendor/sweet-cookie/dist/util/expire.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/fs.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/fs.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/fs.js +13 -0
- package/vendor/sweet-cookie/dist/util/fs.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.js +7 -0
- package/vendor/sweet-cookie/dist/util/hostMatch.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts +5 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.js +58 -0
- package/vendor/sweet-cookie/dist/util/nodeSqlite.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/origins.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/origins.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/origins.js +27 -0
- package/vendor/sweet-cookie/dist/util/origins.js.map +1 -0
- package/vendor/sweet-cookie/dist/util/runtime.d.ts +2 -0
- package/vendor/sweet-cookie/dist/util/runtime.d.ts.map +1 -0
- package/vendor/sweet-cookie/dist/util/runtime.js +8 -0
- package/vendor/sweet-cookie/dist/util/runtime.js.map +1 -0
- package/vendor/sweet-cookie/package.json +40 -0
|
@@ -0,0 +1,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
|
+
}
|