@peers-app/peers-ui 0.23.13 → 0.24.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 (93) hide show
  1. package/biome.json +2 -2
  2. package/dist/components/qr-scanner.d.ts +18 -0
  3. package/dist/components/qr-scanner.js +165 -0
  4. package/dist/components/router.d.ts +1 -1
  5. package/dist/components/router.js +1 -1
  6. package/dist/root-layout/root-layout-host.js +18 -19
  7. package/dist/root-layout/root-layout-host.test.js +14 -9
  8. package/dist/screens/account/account-screen.js +52 -1
  9. package/dist/screens/contacts/contact-list.js +28 -1
  10. package/dist/screens/contacts/index.d.ts +0 -2
  11. package/dist/screens/contacts/index.js +0 -2
  12. package/dist/screens/device-pairing/device-pairing.d.ts +2 -4
  13. package/dist/screens/device-pairing/device-pairing.js +127 -128
  14. package/dist/screens/device-pairing/device-pairing.test.js +51 -15
  15. package/dist/screens/device-pairing/index.d.ts +0 -1
  16. package/dist/screens/device-pairing/index.js +0 -1
  17. package/dist/screens/groups/group-details.js +2 -6
  18. package/dist/screens/groups/group-invite-panel.d.ts +14 -0
  19. package/dist/screens/groups/group-invite-panel.js +125 -0
  20. package/dist/screens/groups/group-members.js +9 -103
  21. package/dist/screens/invites/accept-invite.d.ts +11 -0
  22. package/dist/screens/invites/accept-invite.js +114 -0
  23. package/dist/screens/invites/index.d.ts +7 -0
  24. package/dist/screens/{join-group → invites}/index.js +8 -3
  25. package/dist/screens/invites/invite-capture.d.ts +34 -0
  26. package/dist/screens/invites/invite-capture.js +94 -0
  27. package/dist/screens/invites/invite-share.d.ts +15 -0
  28. package/dist/screens/invites/invite-share.js +40 -0
  29. package/dist/screens/invites/invites-ipc.d.ts +10 -0
  30. package/dist/screens/invites/invites-ipc.js +61 -0
  31. package/dist/screens/invites/invites-screen.d.ts +9 -0
  32. package/dist/screens/invites/invites-screen.js +36 -0
  33. package/dist/screens/invites/pending-invites-list.d.ts +29 -0
  34. package/dist/screens/invites/pending-invites-list.js +156 -0
  35. package/dist/screens/settings/settings-page.js +1 -1
  36. package/dist/screens/setup-user.js +4 -18
  37. package/dist/system-apps/index.d.ts +1 -1
  38. package/dist/system-apps/index.js +5 -5
  39. package/dist/system-apps/{join-group.app.d.ts → invites.app.d.ts} +1 -1
  40. package/dist/system-apps/invites.app.js +9 -0
  41. package/package.json +4 -3
  42. package/src/components/qr-scanner.tsx +180 -0
  43. package/src/components/router.tsx +1 -1
  44. package/src/root-layout/root-layout-host.test.tsx +18 -13
  45. package/src/root-layout/root-layout-host.tsx +23 -21
  46. package/src/screens/account/account-screen.tsx +89 -2
  47. package/src/screens/contacts/contact-list.tsx +64 -4
  48. package/src/screens/contacts/index.ts +0 -2
  49. package/src/screens/device-pairing/device-pairing.test.tsx +61 -16
  50. package/src/screens/device-pairing/device-pairing.tsx +283 -208
  51. package/src/screens/device-pairing/index.ts +0 -1
  52. package/src/screens/groups/group-details.tsx +1 -41
  53. package/src/screens/groups/group-invite-panel.tsx +316 -0
  54. package/src/screens/groups/group-members.tsx +15 -177
  55. package/src/screens/invites/accept-invite.tsx +271 -0
  56. package/src/screens/invites/index.ts +9 -0
  57. package/src/screens/invites/invite-capture.ts +107 -0
  58. package/src/screens/invites/invite-share.tsx +104 -0
  59. package/src/screens/invites/invites-ipc.ts +74 -0
  60. package/src/screens/invites/invites-screen.tsx +78 -0
  61. package/src/screens/invites/pending-invites-list.tsx +224 -0
  62. package/src/screens/settings/settings-page.tsx +2 -2
  63. package/src/screens/setup-user.tsx +29 -47
  64. package/src/system-apps/index.ts +3 -3
  65. package/src/system-apps/invites.app.ts +8 -0
  66. package/dist/screens/contacts/user-connect-invite.d.ts +0 -118
  67. package/dist/screens/contacts/user-connect-invite.js +0 -264
  68. package/dist/screens/contacts/user-connect-invite.test.d.ts +0 -1
  69. package/dist/screens/contacts/user-connect-invite.test.js +0 -215
  70. package/dist/screens/contacts/user-connect.d.ts +0 -8
  71. package/dist/screens/contacts/user-connect.js +0 -487
  72. package/dist/screens/contacts/user-connect.test.d.ts +0 -1
  73. package/dist/screens/contacts/user-connect.test.js +0 -291
  74. package/dist/screens/device-pairing/device-pairing-invite.d.ts +0 -10
  75. package/dist/screens/device-pairing/device-pairing-invite.js +0 -50
  76. package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +0 -1
  77. package/dist/screens/device-pairing/device-pairing-invite.test.js +0 -30
  78. package/dist/screens/groups/group-invite-listener.d.ts +0 -14
  79. package/dist/screens/groups/group-invite-listener.js +0 -93
  80. package/dist/screens/join-group/index.d.ts +0 -2
  81. package/dist/screens/join-group/join-group.d.ts +0 -12
  82. package/dist/screens/join-group/join-group.js +0 -102
  83. package/dist/system-apps/join-group.app.js +0 -9
  84. package/src/screens/contacts/user-connect-invite.test.ts +0 -256
  85. package/src/screens/contacts/user-connect-invite.ts +0 -332
  86. package/src/screens/contacts/user-connect.test.tsx +0 -382
  87. package/src/screens/contacts/user-connect.tsx +0 -883
  88. package/src/screens/device-pairing/device-pairing-invite.test.ts +0 -50
  89. package/src/screens/device-pairing/device-pairing-invite.ts +0 -55
  90. package/src/screens/groups/group-invite-listener.tsx +0 -282
  91. package/src/screens/join-group/index.ts +0 -4
  92. package/src/screens/join-group/join-group.tsx +0 -296
  93. package/src/system-apps/join-group.app.ts +0 -8
@@ -1,256 +0,0 @@
1
- import { USER_CONNECT_TTL_MS } from "@peers-app/peers-sdk";
2
- import {
3
- buildUserConnectInviteUrl,
4
- buildUserConnectShareText,
5
- captureUserConnectInviteFromLocation,
6
- clearUserConnectPendingIntent,
7
- consumeUserConnectPendingIntent,
8
- extractUserConnectCodeFromFragment,
9
- hasPendingUserConnectInvite,
10
- normalizeUserConnectCode,
11
- readUserConnectPendingIntent,
12
- stripUserConnectCodeFromUrl,
13
- USER_CONNECT_INVITE_STORAGE_KEY,
14
- writeUserConnectPendingIntent,
15
- } from "./user-connect-invite";
16
-
17
- function createMemoryStorage(): Storage {
18
- const map = new Map<string, string>();
19
- return {
20
- get length() {
21
- return map.size;
22
- },
23
- clear() {
24
- map.clear();
25
- },
26
- getItem(key: string) {
27
- return map.get(key) ?? null;
28
- },
29
- key(index: number) {
30
- return [...map.keys()][index] ?? null;
31
- },
32
- removeItem(key: string) {
33
- map.delete(key);
34
- },
35
- setItem(key: string, value: string) {
36
- map.set(key, value);
37
- },
38
- };
39
- }
40
-
41
- describe("user-connect-invite", () => {
42
- const validCode = "ABCD1234EFGH";
43
- const formattedCode = "ABCD-1234-EFGH";
44
-
45
- describe("normalizeUserConnectCode", () => {
46
- it("accepts formatted and unformatted valid codes", () => {
47
- expect(normalizeUserConnectCode(validCode)).toBe(validCode);
48
- expect(normalizeUserConnectCode(formattedCode)).toBe(validCode);
49
- expect(normalizeUserConnectCode("abcd-1234-efgh")).toBe(validCode);
50
- });
51
-
52
- it("rejects invalid alphabet and length", () => {
53
- expect(normalizeUserConnectCode("ABCD1234EFG")).toBeNull();
54
- expect(normalizeUserConnectCode("ABCD1234EFGHI")).toBeNull();
55
- expect(normalizeUserConnectCode("ABCD1234EFGI")).toBeNull(); // I invalid in Crockford
56
- expect(normalizeUserConnectCode("A-B-C-D-1-2-3-4-E-F-G-H")).toBeNull();
57
- expect(normalizeUserConnectCode("ABCD 1234 EFGH")).toBeNull();
58
- expect(normalizeUserConnectCode("")).toBeNull();
59
- });
60
- });
61
-
62
- describe("extractUserConnectCodeFromFragment", () => {
63
- it("extracts codes from contacts/connect fragments", () => {
64
- expect(extractUserConnectCodeFromFragment(`#contacts/connect?code=${formattedCode}`)).toBe(
65
- formattedCode,
66
- );
67
- expect(extractUserConnectCodeFromFragment(`contacts/connect?code=${validCode}`)).toBe(
68
- validCode,
69
- );
70
- });
71
-
72
- it("returns null for unrelated routes", () => {
73
- expect(extractUserConnectCodeFromFragment("#contacts/list")).toBeNull();
74
- expect(extractUserConnectCodeFromFragment("#home")).toBeNull();
75
- expect(extractUserConnectCodeFromFragment("#contacts/connect")).toBeNull();
76
- expect(extractUserConnectCodeFromFragment("")).toBeNull();
77
- });
78
- });
79
-
80
- describe("buildUserConnectInviteUrl", () => {
81
- it("builds the canonical peers.app URL with ?app and fragment code", () => {
82
- const url = buildUserConnectInviteUrl(validCode);
83
- expect(url).toBe(
84
- `https://peers.app/?app#contacts/connect?code=${encodeURIComponent(formattedCode)}`,
85
- );
86
- expect(url.includes("?app#")).toBe(true);
87
- expect(url.split("?")[1]?.startsWith("app#")).toBe(true);
88
- const beforeHash = url.split("#")[0];
89
- expect(beforeHash.includes(validCode)).toBe(false);
90
- expect(beforeHash.includes(formattedCode)).toBe(false);
91
- });
92
-
93
- it("accepts an explicit base URL without putting the code in the HTTP query", () => {
94
- const url = buildUserConnectInviteUrl(formattedCode, "https://example.test/");
95
- expect(url.startsWith("https://example.test/?app#")).toBe(true);
96
- expect(url).toContain(`code=${encodeURIComponent(formattedCode)}`);
97
- const query = new URL(url.split("#")[0]).searchParams;
98
- expect(query.has("code")).toBe(false);
99
- expect(query.has("app")).toBe(true);
100
- });
101
- });
102
-
103
- describe("sessionStorage intent lifecycle", () => {
104
- it("writes, reads, and one-time consumes a pending intent", () => {
105
- const storage = createMemoryStorage();
106
- const now = 1_000_000;
107
- writeUserConnectPendingIntent({ code: validCode, capturedAt: now }, storage);
108
-
109
- const read = readUserConnectPendingIntent(storage, now + 1000);
110
- expect(read).toEqual({
111
- ok: true,
112
- intent: { code: validCode, capturedAt: now },
113
- });
114
- expect(hasPendingUserConnectInvite(storage, now + 1000)).toBe(true);
115
-
116
- const consumed = consumeUserConnectPendingIntent(storage, now + 1000);
117
- expect(consumed).toEqual({
118
- ok: true,
119
- intent: { code: validCode, capturedAt: now },
120
- });
121
- expect(storage.getItem(USER_CONNECT_INVITE_STORAGE_KEY)).toBeNull();
122
- expect(consumeUserConnectPendingIntent(storage, now + 1000)).toEqual({
123
- ok: false,
124
- reason: "none",
125
- });
126
- });
127
-
128
- it("expires stale intents using the shared TTL", () => {
129
- const storage = createMemoryStorage();
130
- const now = 1_000_000;
131
- writeUserConnectPendingIntent({ code: validCode, capturedAt: now }, storage);
132
- expect(readUserConnectPendingIntent(storage, now + USER_CONNECT_TTL_MS)).toEqual({
133
- ok: false,
134
- reason: "expired",
135
- });
136
- expect(consumeUserConnectPendingIntent(storage, now + USER_CONNECT_TTL_MS)).toEqual({
137
- ok: false,
138
- reason: "expired",
139
- });
140
- expect(storage.getItem(USER_CONNECT_INVITE_STORAGE_KEY)).toBeNull();
141
- });
142
-
143
- it("rejects implausible future capture timestamps", () => {
144
- const storage = createMemoryStorage();
145
- writeUserConnectPendingIntent({ code: validCode, capturedAt: 20_000 }, storage);
146
- expect(readUserConnectPendingIntent(storage, 10_000)).toEqual({
147
- ok: false,
148
- reason: "expired",
149
- });
150
- });
151
-
152
- it("treats malformed stored data as invalid", () => {
153
- const storage = createMemoryStorage();
154
- storage.setItem(USER_CONNECT_INVITE_STORAGE_KEY, "{not-json");
155
- expect(readUserConnectPendingIntent(storage)).toEqual({ ok: false, reason: "invalid" });
156
- expect(consumeUserConnectPendingIntent(storage)).toEqual({
157
- ok: false,
158
- reason: "invalid",
159
- });
160
- expect(storage.getItem(USER_CONNECT_INVITE_STORAGE_KEY)).toBeNull();
161
- });
162
-
163
- it("clears pending intent explicitly", () => {
164
- const storage = createMemoryStorage();
165
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() }, storage);
166
- clearUserConnectPendingIntent(storage);
167
- expect(readUserConnectPendingIntent(storage)).toEqual({ ok: false, reason: "none" });
168
- });
169
- });
170
-
171
- describe("capture and URL cleanup", () => {
172
- it("captures a valid invite, stores it, and strips the code from the URL", () => {
173
- const storage = createMemoryStorage();
174
- let replacedUrl = "";
175
- const locationLike = {
176
- pathname: "/",
177
- search: "?app",
178
- hash: `#contacts/connect?code=${formattedCode}`,
179
- };
180
- const historyLike = {
181
- state: null,
182
- replaceState: (_state: unknown, _title: string, url?: string | null) => {
183
- replacedUrl = String(url ?? "");
184
- locationLike.hash = replacedUrl.includes("#") ? `#${replacedUrl.split("#")[1]}` : "";
185
- },
186
- };
187
-
188
- const result = captureUserConnectInviteFromLocation({
189
- locationLike,
190
- historyLike,
191
- storage,
192
- nowMs: 5_000,
193
- });
194
-
195
- expect(result).toEqual({
196
- ok: true,
197
- intent: { code: validCode, capturedAt: 5_000 },
198
- });
199
- expect(replacedUrl).toBe("/?app#contacts/connect");
200
- expect(locationLike.hash).toBe("#contacts/connect");
201
- expect(hasPendingUserConnectInvite(storage, 5_000)).toBe(true);
202
- });
203
-
204
- it("stores an invalid marker for malformed codes and still cleans the URL", () => {
205
- const storage = createMemoryStorage();
206
- const locationLike = {
207
- pathname: "/",
208
- search: "?app",
209
- hash: "#contacts/connect?code=BAD",
210
- };
211
- const historyLike = {
212
- state: null,
213
- replaceState: (_state: unknown, _title: string, url?: string | null) => {
214
- locationLike.hash = String(url ?? "").includes("#")
215
- ? `#${String(url).split("#")[1]}`
216
- : "";
217
- },
218
- };
219
-
220
- expect(
221
- captureUserConnectInviteFromLocation({
222
- locationLike,
223
- historyLike,
224
- storage,
225
- nowMs: 9_000,
226
- }),
227
- ).toEqual({ ok: false, reason: "invalid" });
228
- expect(locationLike.hash).toBe("#contacts/connect");
229
- expect(readUserConnectPendingIntent(storage, 9_000)).toEqual({
230
- ok: false,
231
- reason: "invalid",
232
- });
233
- });
234
-
235
- it("stripUserConnectCodeFromUrl is a no-op without a code", () => {
236
- const locationLike = { pathname: "/", search: "", hash: "#home" };
237
- let calls = 0;
238
- stripUserConnectCodeFromUrl(locationLike, {
239
- state: null,
240
- replaceState: () => {
241
- calls += 1;
242
- },
243
- });
244
- expect(calls).toBe(0);
245
- });
246
- });
247
-
248
- describe("buildUserConnectShareText", () => {
249
- it("mentions expiry and that both people need Peers open", () => {
250
- const text = buildUserConnectShareText("https://peers.app/?app#contacts/connect?code=X");
251
- expect(text).toContain("https://peers.app/?app#contacts/connect?code=X");
252
- expect(text.toLowerCase()).toContain("expire");
253
- expect(text.toLowerCase()).toContain("peers open");
254
- });
255
- });
256
- });
@@ -1,332 +0,0 @@
1
- /**
2
- * Invite URL and pending-intent helpers for the user-connect QR/link flow.
3
- *
4
- * Invite codes stay in the URL fragment (never the HTTP query) and are moved
5
- * into tab-scoped sessionStorage before startup route restoration can persist
6
- * them into `mainContentPath`.
7
- */
8
-
9
- import {
10
- formatConnectionCode,
11
- isValidConnectionCode,
12
- parseConnectionCode,
13
- USER_CONNECT_TTL_MS,
14
- } from "@peers-app/peers-sdk";
15
-
16
- /** sessionStorage key for a captured inbound invite. */
17
- export const USER_CONNECT_INVITE_STORAGE_KEY = "peers.userConnectInvite";
18
-
19
- /** Default production launch origin used when building shareable invite links. */
20
- export const DEFAULT_USER_CONNECT_BASE_URL = "https://peers.app";
21
-
22
- /**
23
- * Serializable pending invite captured from a deep link or stored for post-setup resume.
24
- */
25
- export interface UserConnectPendingIntent {
26
- /** Normalized 12-character Crockford code (no dashes). */
27
- code: string;
28
- /** Epoch ms when the intent was captured. */
29
- capturedAt: number;
30
- }
31
-
32
- /**
33
- * Result of attempting to capture or read a pending invite.
34
- */
35
- export type UserConnectInviteCaptureResult =
36
- | { ok: true; intent: UserConnectPendingIntent }
37
- | { ok: false; reason: "none" | "invalid" | "expired" };
38
-
39
- interface StoredErrorMarker {
40
- error: "invalid" | "expired";
41
- capturedAt: number;
42
- }
43
-
44
- type StoredInvitePayload = UserConnectPendingIntent | StoredErrorMarker;
45
-
46
- /**
47
- * Build the canonical shareable invite URL.
48
- * Keeps the bearer code in the fragment and uses `?app` so signed-out PWA
49
- * recipients receive the app shell instead of the marketing landing page.
50
- * @param code Connection code (formatted or raw)
51
- * @param baseUrl Launch origin (no trailing slash); defaults to peers.app
52
- * @returns Absolute invite URL
53
- */
54
- export function buildUserConnectInviteUrl(
55
- code: string,
56
- baseUrl: string = DEFAULT_USER_CONNECT_BASE_URL,
57
- ): string {
58
- const normalized = normalizeUserConnectCode(code);
59
- if (!normalized) {
60
- throw new Error("Invalid connection code");
61
- }
62
- const formatted = formatConnectionCode(normalized);
63
- const origin = baseUrl.replace(/\/$/, "");
64
- return `${origin}/?app#contacts/connect?code=${encodeURIComponent(formatted)}`;
65
- }
66
-
67
- /**
68
- * Extract a connection code from a `contacts/connect` hash fragment.
69
- * Accepts `#contacts/connect?code=…` or `contacts/connect?code=…`.
70
- * @param hashOrPath Hash or path fragment from `location.hash` / a full URL
71
- * @returns Raw code string from the query, or null when the route is not an invite
72
- */
73
- export function extractUserConnectCodeFromFragment(hashOrPath: string): string | null {
74
- const raw = hashOrPath.startsWith("#") ? hashOrPath.slice(1) : hashOrPath;
75
- const [pathPart, queryPart] = raw.split("?");
76
- if (pathPart !== "contacts/connect") {
77
- return null;
78
- }
79
- if (!queryPart) {
80
- return null;
81
- }
82
- const params = new URLSearchParams(queryPart);
83
- const code = params.get("code");
84
- if (code === null) {
85
- return null;
86
- }
87
- const decoded = code.trim();
88
- return decoded.length > 0 ? decoded : null;
89
- }
90
-
91
- /**
92
- * Validate and normalize a connection code to 12 Crockford characters.
93
- * @param code Candidate code (formatted or raw)
94
- * @returns Normalized code, or null when invalid
95
- */
96
- export function normalizeUserConnectCode(code: string): string | null {
97
- const trimmed = code.trim().toUpperCase();
98
- const isRaw = /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{12}$/.test(trimmed);
99
- const isFormatted =
100
- /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{4}-[0-9ABCDEFGHJKMNPQRSTVWXYZ]{4}-[0-9ABCDEFGHJKMNPQRSTVWXYZ]{4}$/.test(
101
- trimmed,
102
- );
103
- if (!isRaw && !isFormatted) {
104
- return null;
105
- }
106
- const compact = trimmed.replaceAll("-", "");
107
- if (!isValidConnectionCode(compact)) {
108
- return null;
109
- }
110
- try {
111
- return parseConnectionCode(compact).code;
112
- } catch {
113
- return null;
114
- }
115
- }
116
-
117
- /**
118
- * Whether a pending intent is still within the local invite TTL.
119
- * @param intent Captured intent
120
- * @param nowMs Current epoch ms (injectable for tests)
121
- * @returns True when the intent has not expired
122
- */
123
- export function isUserConnectIntentFresh(
124
- intent: Pick<UserConnectPendingIntent, "capturedAt">,
125
- nowMs: number = Date.now(),
126
- ): boolean {
127
- return intent.capturedAt <= nowMs && nowMs - intent.capturedAt < USER_CONNECT_TTL_MS;
128
- }
129
-
130
- /**
131
- * Replace the visible URL so the bearer code is no longer in the address bar.
132
- * Leaves `?app` and a non-sensitive `#contacts/connect` route shell when present.
133
- * @param locationLike Browser location (defaults to `window.location`)
134
- * @param historyLike History API (defaults to `window.history`)
135
- */
136
- export function stripUserConnectCodeFromUrl(
137
- locationLike: Pick<Location, "pathname" | "search" | "hash"> = window.location,
138
- historyLike: Pick<History, "replaceState" | "state"> = window.history,
139
- ): void {
140
- if (!locationLike.hash.includes("code=")) {
141
- return;
142
- }
143
- const path = locationLike.hash.startsWith("#")
144
- ? locationLike.hash.slice(1).split("?")[0]
145
- : locationLike.hash.split("?")[0];
146
- const nextHash = path === "contacts/connect" ? "#contacts/connect" : "";
147
- const nextUrl = `${locationLike.pathname}${locationLike.search}${nextHash}`;
148
- historyLike.replaceState(historyLike.state ?? null, "", nextUrl);
149
- }
150
-
151
- /**
152
- * Persist a pending invite in tab-scoped sessionStorage.
153
- * @param intent Valid intent to store
154
- * @param storage Storage backend (defaults to sessionStorage)
155
- */
156
- export function writeUserConnectPendingIntent(
157
- intent: UserConnectPendingIntent,
158
- storage: Storage = sessionStorage,
159
- ): void {
160
- const payload: StoredInvitePayload = intent;
161
- storage.setItem(USER_CONNECT_INVITE_STORAGE_KEY, JSON.stringify(payload));
162
- }
163
-
164
- function writeErrorMarker(
165
- error: StoredErrorMarker["error"],
166
- capturedAt: number,
167
- storage: Storage,
168
- ): void {
169
- const payload: StoredErrorMarker = { error, capturedAt };
170
- storage.setItem(USER_CONNECT_INVITE_STORAGE_KEY, JSON.stringify(payload));
171
- }
172
-
173
- function parseStoredPayload(raw: string): StoredInvitePayload | null {
174
- try {
175
- const parsed = JSON.parse(raw) as Partial<StoredInvitePayload>;
176
- const marker = parsed as Partial<StoredErrorMarker>;
177
- if (marker.error === "invalid" || marker.error === "expired") {
178
- const capturedAt = marker.capturedAt;
179
- if (typeof capturedAt !== "number" || !Number.isFinite(capturedAt)) {
180
- return null;
181
- }
182
- return { error: marker.error, capturedAt };
183
- }
184
- if (
185
- typeof (parsed as UserConnectPendingIntent).code === "string" &&
186
- typeof (parsed as UserConnectPendingIntent).capturedAt === "number" &&
187
- Number.isFinite((parsed as UserConnectPendingIntent).capturedAt)
188
- ) {
189
- return {
190
- code: (parsed as UserConnectPendingIntent).code,
191
- capturedAt: (parsed as UserConnectPendingIntent).capturedAt,
192
- };
193
- }
194
- return null;
195
- } catch {
196
- return null;
197
- }
198
- }
199
-
200
- /**
201
- * Read a pending invite without consuming it.
202
- * Expired entries are cleared. Invalid markers remain until consumed/cleared
203
- * so the connection screen can show recovery UI.
204
- * @param storage Storage backend
205
- * @param nowMs Current epoch ms
206
- * @returns Capture result
207
- */
208
- export function readUserConnectPendingIntent(
209
- storage: Storage = sessionStorage,
210
- nowMs: number = Date.now(),
211
- ): UserConnectInviteCaptureResult {
212
- const raw = storage.getItem(USER_CONNECT_INVITE_STORAGE_KEY);
213
- if (!raw) {
214
- return { ok: false, reason: "none" };
215
- }
216
- const parsed = parseStoredPayload(raw);
217
- if (!parsed) {
218
- writeErrorMarker("invalid", nowMs, storage);
219
- return { ok: false, reason: "invalid" };
220
- }
221
- if ("error" in parsed) {
222
- return { ok: false, reason: parsed.error };
223
- }
224
- const normalized = normalizeUserConnectCode(parsed.code);
225
- if (!normalized) {
226
- writeErrorMarker("invalid", parsed.capturedAt, storage);
227
- return { ok: false, reason: "invalid" };
228
- }
229
- const intent: UserConnectPendingIntent = {
230
- code: normalized,
231
- capturedAt: parsed.capturedAt,
232
- };
233
- if (!isUserConnectIntentFresh(intent, nowMs)) {
234
- writeErrorMarker("expired", parsed.capturedAt, storage);
235
- return { ok: false, reason: "expired" };
236
- }
237
- return { ok: true, intent };
238
- }
239
-
240
- /**
241
- * Read and remove a pending invite in one step (one-time consumption).
242
- * @param storage Storage backend
243
- * @param nowMs Current epoch ms
244
- * @returns Capture result for the consumed intent
245
- */
246
- export function consumeUserConnectPendingIntent(
247
- storage: Storage = sessionStorage,
248
- nowMs: number = Date.now(),
249
- ): UserConnectInviteCaptureResult {
250
- const result = readUserConnectPendingIntent(storage, nowMs);
251
- storage.removeItem(USER_CONNECT_INVITE_STORAGE_KEY);
252
- return result;
253
- }
254
-
255
- /**
256
- * Remove any pending invite from sessionStorage.
257
- * @param storage Storage backend
258
- */
259
- export function clearUserConnectPendingIntent(storage: Storage = sessionStorage): void {
260
- storage.removeItem(USER_CONNECT_INVITE_STORAGE_KEY);
261
- }
262
-
263
- /**
264
- * Capture an inbound invite from the current location into sessionStorage and
265
- * strip the code from the address bar. Safe to call multiple times; a second
266
- * call with no code in the URL leaves existing storage alone.
267
- * @param options Optional location/history/storage/clock overrides for tests
268
- * @returns Capture result
269
- */
270
- export function captureUserConnectInviteFromLocation(options?: {
271
- locationLike?: Pick<Location, "pathname" | "search" | "hash">;
272
- historyLike?: Pick<History, "replaceState" | "state">;
273
- storage?: Storage;
274
- nowMs?: number;
275
- }): UserConnectInviteCaptureResult {
276
- const locationLike = options?.locationLike ?? window.location;
277
- const historyLike = options?.historyLike ?? window.history;
278
- const storage = options?.storage ?? sessionStorage;
279
- const nowMs = options?.nowMs ?? Date.now();
280
-
281
- const extracted = extractUserConnectCodeFromFragment(locationLike.hash);
282
- if (extracted === null) {
283
- const rawFragment = locationLike.hash.startsWith("#")
284
- ? locationLike.hash.slice(1)
285
- : locationLike.hash;
286
- const [path, query = ""] = rawFragment.split("?");
287
- if (path === "contacts/connect" && new URLSearchParams(query).has("code")) {
288
- stripUserConnectCodeFromUrl(locationLike, historyLike);
289
- writeErrorMarker("invalid", nowMs, storage);
290
- return { ok: false, reason: "invalid" };
291
- }
292
- return readUserConnectPendingIntent(storage, nowMs);
293
- }
294
-
295
- stripUserConnectCodeFromUrl(locationLike, historyLike);
296
-
297
- const normalized = normalizeUserConnectCode(extracted);
298
- if (!normalized) {
299
- writeErrorMarker("invalid", nowMs, storage);
300
- return { ok: false, reason: "invalid" };
301
- }
302
-
303
- const intent: UserConnectPendingIntent = { code: normalized, capturedAt: nowMs };
304
- writeUserConnectPendingIntent(intent, storage);
305
- return { ok: true, intent };
306
- }
307
-
308
- /**
309
- * Whether sessionStorage currently holds a fresh, valid pending invite.
310
- * @param storage Storage backend
311
- * @param nowMs Current epoch ms
312
- */
313
- export function hasPendingUserConnectInvite(
314
- storage: Storage = sessionStorage,
315
- nowMs: number = Date.now(),
316
- ): boolean {
317
- return readUserConnectPendingIntent(storage, nowMs).ok;
318
- }
319
-
320
- /**
321
- * Share text for the Web Share API / clipboard companion copy.
322
- * @param inviteUrl Canonical invite URL
323
- * @returns Human-readable share body
324
- */
325
- export function buildUserConnectShareText(inviteUrl: string): string {
326
- return [
327
- "Connect with me on Peers:",
328
- inviteUrl,
329
- "",
330
- "Both of us need Peers open. This invite expires in 10 minutes.",
331
- ].join("\n");
332
- }