@peers-app/peers-ui 0.20.3 → 0.20.5
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/dist/root-layout/root-layout-host.js +26 -0
- package/dist/root-layout/root-layout-host.test.d.ts +1 -0
- package/dist/root-layout/root-layout-host.test.js +84 -0
- package/dist/screens/contacts/user-connect-invite.d.ts +118 -0
- package/dist/screens/contacts/user-connect-invite.js +264 -0
- package/dist/screens/contacts/user-connect-invite.test.d.ts +1 -0
- package/dist/screens/contacts/user-connect-invite.test.js +215 -0
- package/dist/screens/contacts/user-connect.d.ts +7 -0
- package/dist/screens/contacts/user-connect.js +402 -126
- package/dist/screens/contacts/user-connect.test.d.ts +1 -0
- package/dist/screens/contacts/user-connect.test.js +291 -0
- package/package.json +5 -4
- package/src/root-layout/root-layout-host.test.tsx +106 -0
- package/src/root-layout/root-layout-host.tsx +33 -2
- package/src/screens/contacts/user-connect-invite.test.ts +256 -0
- package/src/screens/contacts/user-connect-invite.ts +332 -0
- package/src/screens/contacts/user-connect.test.tsx +382 -0
- package/src/screens/contacts/user-connect.tsx +735 -310
|
@@ -20,6 +20,7 @@ const react_1 = require("react");
|
|
|
20
20
|
const command_palette_ui_1 = require("../command-palette/command-palette-ui");
|
|
21
21
|
const globals_1 = require("../globals");
|
|
22
22
|
const hooks_1 = require("../hooks");
|
|
23
|
+
const user_connect_invite_1 = require("../screens/contacts/user-connect-invite");
|
|
23
24
|
const color_mode_dropdown_1 = require("../screens/settings/color-mode-dropdown");
|
|
24
25
|
const setup_user_1 = require("../screens/setup-user");
|
|
25
26
|
const welcome_modal_1 = require("../screens/welcome-modal");
|
|
@@ -34,10 +35,31 @@ const root_layout_state_1 = require("./root-layout-state");
|
|
|
34
35
|
* order downstream is stable across the user/no-user branches.
|
|
35
36
|
*/
|
|
36
37
|
function RootLayoutHost() {
|
|
38
|
+
(0, react_1.useState)(() => {
|
|
39
|
+
(0, user_connect_invite_1.captureUserConnectInviteFromLocation)();
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
42
|
+
const userIdRef = (0, react_1.useRef)(undefined);
|
|
37
43
|
const userId = (0, hooks_1.usePromise)(async () => {
|
|
38
44
|
const _userId = await peers_sdk_1.rpcServerCalls.getUserId();
|
|
39
45
|
return _userId || null;
|
|
40
46
|
});
|
|
47
|
+
userIdRef.current = userId;
|
|
48
|
+
(0, react_1.useEffect)(() => {
|
|
49
|
+
const captureInviteFromHashChange = () => {
|
|
50
|
+
const previousHash = window.location.hash;
|
|
51
|
+
(0, user_connect_invite_1.captureUserConnectInviteFromLocation)();
|
|
52
|
+
if (window.location.hash === previousHash) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Signed-out captures stay in sessionStorage until setup finishes.
|
|
56
|
+
if (userIdRef.current) {
|
|
57
|
+
(0, globals_1.mainContentPath)("contacts/connect");
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
window.addEventListener("hashchange", captureInviteFromHashChange);
|
|
61
|
+
return () => window.removeEventListener("hashchange", captureInviteFromHashChange);
|
|
62
|
+
}, []);
|
|
41
63
|
if (userId === undefined) {
|
|
42
64
|
return;
|
|
43
65
|
}
|
|
@@ -67,6 +89,10 @@ function RootLayoutBootstrap(props) {
|
|
|
67
89
|
tabs_state_1.recentlyUsedApps.loadingPromise,
|
|
68
90
|
peers_sdk_1.hasShownWelcomeModal.loadingPromise,
|
|
69
91
|
]);
|
|
92
|
+
const pendingInvite = (0, user_connect_invite_1.readUserConnectPendingIntent)();
|
|
93
|
+
if (pendingInvite.ok || pendingInvite.reason !== "none") {
|
|
94
|
+
(0, globals_1.mainContentPath)("contacts/connect");
|
|
95
|
+
}
|
|
70
96
|
return true;
|
|
71
97
|
});
|
|
72
98
|
const [showWelcomeModal, setShowWelcomeModal] = (0, react_1.useState)(false);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const react_1 = require("@testing-library/react");
|
|
5
|
+
const globals_1 = require("../globals");
|
|
6
|
+
const user_connect_invite_1 = require("../screens/contacts/user-connect-invite");
|
|
7
|
+
const root_layout_host_1 = require("./root-layout-host");
|
|
8
|
+
jest.mock("@peers-app/peers-sdk", () => {
|
|
9
|
+
const actual = jest.requireActual("@peers-app/peers-sdk");
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
hasShownWelcomeModal: Object.assign(jest.fn(() => true), {
|
|
13
|
+
loadingPromise: Promise.resolve(),
|
|
14
|
+
}),
|
|
15
|
+
rpcServerCalls: {
|
|
16
|
+
...actual.rpcServerCalls,
|
|
17
|
+
getUserId: jest.fn(async () => "local-user"),
|
|
18
|
+
},
|
|
19
|
+
setDefaultClientUserContext: jest.fn(),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
jest.mock("../command-palette/command-palette-ui", () => ({
|
|
23
|
+
CommandPaletteOverlay: () => null,
|
|
24
|
+
}));
|
|
25
|
+
jest.mock("../globals", () => ({
|
|
26
|
+
loadGlobals: jest.fn(async () => true),
|
|
27
|
+
mainContentPath: jest.fn(),
|
|
28
|
+
}));
|
|
29
|
+
jest.mock("../hooks", () => ({
|
|
30
|
+
useObservable: (observable) => [observable(), observable],
|
|
31
|
+
usePromise: (promiseOrFactory) => {
|
|
32
|
+
if (typeof promiseOrFactory === "function" &&
|
|
33
|
+
Function.prototype.toString.call(promiseOrFactory).includes("getUserId")) {
|
|
34
|
+
return "local-user";
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
jest.mock("../screens/settings/color-mode-dropdown", () => ({
|
|
40
|
+
colorMode: jest.fn(() => "light"),
|
|
41
|
+
}));
|
|
42
|
+
jest.mock("../screens/setup-user", () => ({
|
|
43
|
+
SetupUser: () => null,
|
|
44
|
+
}));
|
|
45
|
+
jest.mock("../screens/welcome-modal", () => ({
|
|
46
|
+
WelcomeModal: () => null,
|
|
47
|
+
}));
|
|
48
|
+
jest.mock("../tabs-layout/tabs-state", () => ({
|
|
49
|
+
activeTabId: { loadingPromise: Promise.resolve() },
|
|
50
|
+
activeTabs: { loadingPromise: Promise.resolve() },
|
|
51
|
+
recentlyUsedApps: { loadingPromise: Promise.resolve() },
|
|
52
|
+
}));
|
|
53
|
+
jest.mock("../ui-router/routes-loader", () => ({
|
|
54
|
+
loadAllRoutes: jest.fn(async () => undefined),
|
|
55
|
+
}));
|
|
56
|
+
jest.mock("./global-overlays", () => ({
|
|
57
|
+
globalOverlays: jest.fn(() => []),
|
|
58
|
+
}));
|
|
59
|
+
jest.mock("./root-layout-registry", () => ({
|
|
60
|
+
DEFAULT_ROOT_LAYOUT_ID: "default",
|
|
61
|
+
getRootLayout: jest.fn(() => ({ Component: () => null })),
|
|
62
|
+
}));
|
|
63
|
+
jest.mock("./root-layout-state", () => ({
|
|
64
|
+
selectedRootLayoutId: jest.fn(() => undefined),
|
|
65
|
+
}));
|
|
66
|
+
describe("RootLayoutHost", () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
sessionStorage.clear();
|
|
69
|
+
window.history.replaceState(null, "", "/");
|
|
70
|
+
jest.clearAllMocks();
|
|
71
|
+
});
|
|
72
|
+
it("captures and sanitizes invite links opened after startup", () => {
|
|
73
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(root_layout_host_1.RootLayoutHost, {}));
|
|
74
|
+
window.history.replaceState(null, "", `/?app#contacts/connect?code=${encodeURIComponent("ABCD-1234-EFGH")}`);
|
|
75
|
+
(0, react_1.fireEvent)(window, new HashChangeEvent("hashchange"));
|
|
76
|
+
expect(window.location.hash).toBe("#contacts/connect");
|
|
77
|
+
expect((0, user_connect_invite_1.readUserConnectPendingIntent)()).toEqual({
|
|
78
|
+
ok: true,
|
|
79
|
+
intent: expect.objectContaining({ code: "ABCD1234EFGH" }),
|
|
80
|
+
});
|
|
81
|
+
expect(globals_1.mainContentPath).toHaveBeenCalledWith("contacts/connect");
|
|
82
|
+
expect(globals_1.mainContentPath).not.toHaveBeenCalledWith(expect.stringContaining("code="));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
/** sessionStorage key for a captured inbound invite. */
|
|
9
|
+
export declare const USER_CONNECT_INVITE_STORAGE_KEY = "peers.userConnectInvite";
|
|
10
|
+
/** Default production launch origin used when building shareable invite links. */
|
|
11
|
+
export declare const DEFAULT_USER_CONNECT_BASE_URL = "https://peers.app";
|
|
12
|
+
/**
|
|
13
|
+
* Serializable pending invite captured from a deep link or stored for post-setup resume.
|
|
14
|
+
*/
|
|
15
|
+
export interface UserConnectPendingIntent {
|
|
16
|
+
/** Normalized 12-character Crockford code (no dashes). */
|
|
17
|
+
code: string;
|
|
18
|
+
/** Epoch ms when the intent was captured. */
|
|
19
|
+
capturedAt: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Result of attempting to capture or read a pending invite.
|
|
23
|
+
*/
|
|
24
|
+
export type UserConnectInviteCaptureResult = {
|
|
25
|
+
ok: true;
|
|
26
|
+
intent: UserConnectPendingIntent;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: "none" | "invalid" | "expired";
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Build the canonical shareable invite URL.
|
|
33
|
+
* Keeps the bearer code in the fragment and uses `?app` so signed-out PWA
|
|
34
|
+
* recipients receive the app shell instead of the marketing landing page.
|
|
35
|
+
* @param code Connection code (formatted or raw)
|
|
36
|
+
* @param baseUrl Launch origin (no trailing slash); defaults to peers.app
|
|
37
|
+
* @returns Absolute invite URL
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildUserConnectInviteUrl(code: string, baseUrl?: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Extract a connection code from a `contacts/connect` hash fragment.
|
|
42
|
+
* Accepts `#contacts/connect?code=…` or `contacts/connect?code=…`.
|
|
43
|
+
* @param hashOrPath Hash or path fragment from `location.hash` / a full URL
|
|
44
|
+
* @returns Raw code string from the query, or null when the route is not an invite
|
|
45
|
+
*/
|
|
46
|
+
export declare function extractUserConnectCodeFromFragment(hashOrPath: string): string | null;
|
|
47
|
+
/**
|
|
48
|
+
* Validate and normalize a connection code to 12 Crockford characters.
|
|
49
|
+
* @param code Candidate code (formatted or raw)
|
|
50
|
+
* @returns Normalized code, or null when invalid
|
|
51
|
+
*/
|
|
52
|
+
export declare function normalizeUserConnectCode(code: string): string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a pending intent is still within the local invite TTL.
|
|
55
|
+
* @param intent Captured intent
|
|
56
|
+
* @param nowMs Current epoch ms (injectable for tests)
|
|
57
|
+
* @returns True when the intent has not expired
|
|
58
|
+
*/
|
|
59
|
+
export declare function isUserConnectIntentFresh(intent: Pick<UserConnectPendingIntent, "capturedAt">, nowMs?: number): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Replace the visible URL so the bearer code is no longer in the address bar.
|
|
62
|
+
* Leaves `?app` and a non-sensitive `#contacts/connect` route shell when present.
|
|
63
|
+
* @param locationLike Browser location (defaults to `window.location`)
|
|
64
|
+
* @param historyLike History API (defaults to `window.history`)
|
|
65
|
+
*/
|
|
66
|
+
export declare function stripUserConnectCodeFromUrl(locationLike?: Pick<Location, "pathname" | "search" | "hash">, historyLike?: Pick<History, "replaceState" | "state">): void;
|
|
67
|
+
/**
|
|
68
|
+
* Persist a pending invite in tab-scoped sessionStorage.
|
|
69
|
+
* @param intent Valid intent to store
|
|
70
|
+
* @param storage Storage backend (defaults to sessionStorage)
|
|
71
|
+
*/
|
|
72
|
+
export declare function writeUserConnectPendingIntent(intent: UserConnectPendingIntent, storage?: Storage): void;
|
|
73
|
+
/**
|
|
74
|
+
* Read a pending invite without consuming it.
|
|
75
|
+
* Expired entries are cleared. Invalid markers remain until consumed/cleared
|
|
76
|
+
* so the connection screen can show recovery UI.
|
|
77
|
+
* @param storage Storage backend
|
|
78
|
+
* @param nowMs Current epoch ms
|
|
79
|
+
* @returns Capture result
|
|
80
|
+
*/
|
|
81
|
+
export declare function readUserConnectPendingIntent(storage?: Storage, nowMs?: number): UserConnectInviteCaptureResult;
|
|
82
|
+
/**
|
|
83
|
+
* Read and remove a pending invite in one step (one-time consumption).
|
|
84
|
+
* @param storage Storage backend
|
|
85
|
+
* @param nowMs Current epoch ms
|
|
86
|
+
* @returns Capture result for the consumed intent
|
|
87
|
+
*/
|
|
88
|
+
export declare function consumeUserConnectPendingIntent(storage?: Storage, nowMs?: number): UserConnectInviteCaptureResult;
|
|
89
|
+
/**
|
|
90
|
+
* Remove any pending invite from sessionStorage.
|
|
91
|
+
* @param storage Storage backend
|
|
92
|
+
*/
|
|
93
|
+
export declare function clearUserConnectPendingIntent(storage?: Storage): void;
|
|
94
|
+
/**
|
|
95
|
+
* Capture an inbound invite from the current location into sessionStorage and
|
|
96
|
+
* strip the code from the address bar. Safe to call multiple times; a second
|
|
97
|
+
* call with no code in the URL leaves existing storage alone.
|
|
98
|
+
* @param options Optional location/history/storage/clock overrides for tests
|
|
99
|
+
* @returns Capture result
|
|
100
|
+
*/
|
|
101
|
+
export declare function captureUserConnectInviteFromLocation(options?: {
|
|
102
|
+
locationLike?: Pick<Location, "pathname" | "search" | "hash">;
|
|
103
|
+
historyLike?: Pick<History, "replaceState" | "state">;
|
|
104
|
+
storage?: Storage;
|
|
105
|
+
nowMs?: number;
|
|
106
|
+
}): UserConnectInviteCaptureResult;
|
|
107
|
+
/**
|
|
108
|
+
* Whether sessionStorage currently holds a fresh, valid pending invite.
|
|
109
|
+
* @param storage Storage backend
|
|
110
|
+
* @param nowMs Current epoch ms
|
|
111
|
+
*/
|
|
112
|
+
export declare function hasPendingUserConnectInvite(storage?: Storage, nowMs?: number): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Share text for the Web Share API / clipboard companion copy.
|
|
115
|
+
* @param inviteUrl Canonical invite URL
|
|
116
|
+
* @returns Human-readable share body
|
|
117
|
+
*/
|
|
118
|
+
export declare function buildUserConnectShareText(inviteUrl: string): string;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Invite URL and pending-intent helpers for the user-connect QR/link flow.
|
|
4
|
+
*
|
|
5
|
+
* Invite codes stay in the URL fragment (never the HTTP query) and are moved
|
|
6
|
+
* into tab-scoped sessionStorage before startup route restoration can persist
|
|
7
|
+
* them into `mainContentPath`.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.DEFAULT_USER_CONNECT_BASE_URL = exports.USER_CONNECT_INVITE_STORAGE_KEY = void 0;
|
|
11
|
+
exports.buildUserConnectInviteUrl = buildUserConnectInviteUrl;
|
|
12
|
+
exports.extractUserConnectCodeFromFragment = extractUserConnectCodeFromFragment;
|
|
13
|
+
exports.normalizeUserConnectCode = normalizeUserConnectCode;
|
|
14
|
+
exports.isUserConnectIntentFresh = isUserConnectIntentFresh;
|
|
15
|
+
exports.stripUserConnectCodeFromUrl = stripUserConnectCodeFromUrl;
|
|
16
|
+
exports.writeUserConnectPendingIntent = writeUserConnectPendingIntent;
|
|
17
|
+
exports.readUserConnectPendingIntent = readUserConnectPendingIntent;
|
|
18
|
+
exports.consumeUserConnectPendingIntent = consumeUserConnectPendingIntent;
|
|
19
|
+
exports.clearUserConnectPendingIntent = clearUserConnectPendingIntent;
|
|
20
|
+
exports.captureUserConnectInviteFromLocation = captureUserConnectInviteFromLocation;
|
|
21
|
+
exports.hasPendingUserConnectInvite = hasPendingUserConnectInvite;
|
|
22
|
+
exports.buildUserConnectShareText = buildUserConnectShareText;
|
|
23
|
+
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
24
|
+
/** sessionStorage key for a captured inbound invite. */
|
|
25
|
+
exports.USER_CONNECT_INVITE_STORAGE_KEY = "peers.userConnectInvite";
|
|
26
|
+
/** Default production launch origin used when building shareable invite links. */
|
|
27
|
+
exports.DEFAULT_USER_CONNECT_BASE_URL = "https://peers.app";
|
|
28
|
+
/**
|
|
29
|
+
* Build the canonical shareable invite URL.
|
|
30
|
+
* Keeps the bearer code in the fragment and uses `?app` so signed-out PWA
|
|
31
|
+
* recipients receive the app shell instead of the marketing landing page.
|
|
32
|
+
* @param code Connection code (formatted or raw)
|
|
33
|
+
* @param baseUrl Launch origin (no trailing slash); defaults to peers.app
|
|
34
|
+
* @returns Absolute invite URL
|
|
35
|
+
*/
|
|
36
|
+
function buildUserConnectInviteUrl(code, baseUrl = exports.DEFAULT_USER_CONNECT_BASE_URL) {
|
|
37
|
+
const normalized = normalizeUserConnectCode(code);
|
|
38
|
+
if (!normalized) {
|
|
39
|
+
throw new Error("Invalid connection code");
|
|
40
|
+
}
|
|
41
|
+
const formatted = (0, peers_sdk_1.formatConnectionCode)(normalized);
|
|
42
|
+
const origin = baseUrl.replace(/\/$/, "");
|
|
43
|
+
return `${origin}/?app#contacts/connect?code=${encodeURIComponent(formatted)}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Extract a connection code from a `contacts/connect` hash fragment.
|
|
47
|
+
* Accepts `#contacts/connect?code=…` or `contacts/connect?code=…`.
|
|
48
|
+
* @param hashOrPath Hash or path fragment from `location.hash` / a full URL
|
|
49
|
+
* @returns Raw code string from the query, or null when the route is not an invite
|
|
50
|
+
*/
|
|
51
|
+
function extractUserConnectCodeFromFragment(hashOrPath) {
|
|
52
|
+
const raw = hashOrPath.startsWith("#") ? hashOrPath.slice(1) : hashOrPath;
|
|
53
|
+
const [pathPart, queryPart] = raw.split("?");
|
|
54
|
+
if (pathPart !== "contacts/connect") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (!queryPart) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const params = new URLSearchParams(queryPart);
|
|
61
|
+
const code = params.get("code");
|
|
62
|
+
if (code === null) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const decoded = code.trim();
|
|
66
|
+
return decoded.length > 0 ? decoded : null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Validate and normalize a connection code to 12 Crockford characters.
|
|
70
|
+
* @param code Candidate code (formatted or raw)
|
|
71
|
+
* @returns Normalized code, or null when invalid
|
|
72
|
+
*/
|
|
73
|
+
function normalizeUserConnectCode(code) {
|
|
74
|
+
const trimmed = code.trim().toUpperCase();
|
|
75
|
+
const isRaw = /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{12}$/.test(trimmed);
|
|
76
|
+
const isFormatted = /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{4}-[0-9ABCDEFGHJKMNPQRSTVWXYZ]{4}-[0-9ABCDEFGHJKMNPQRSTVWXYZ]{4}$/.test(trimmed);
|
|
77
|
+
if (!isRaw && !isFormatted) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const compact = trimmed.replaceAll("-", "");
|
|
81
|
+
if (!(0, peers_sdk_1.isValidConnectionCode)(compact)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return (0, peers_sdk_1.parseConnectionCode)(compact).code;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether a pending intent is still within the local invite TTL.
|
|
93
|
+
* @param intent Captured intent
|
|
94
|
+
* @param nowMs Current epoch ms (injectable for tests)
|
|
95
|
+
* @returns True when the intent has not expired
|
|
96
|
+
*/
|
|
97
|
+
function isUserConnectIntentFresh(intent, nowMs = Date.now()) {
|
|
98
|
+
return intent.capturedAt <= nowMs && nowMs - intent.capturedAt < peers_sdk_1.USER_CONNECT_TTL_MS;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Replace the visible URL so the bearer code is no longer in the address bar.
|
|
102
|
+
* Leaves `?app` and a non-sensitive `#contacts/connect` route shell when present.
|
|
103
|
+
* @param locationLike Browser location (defaults to `window.location`)
|
|
104
|
+
* @param historyLike History API (defaults to `window.history`)
|
|
105
|
+
*/
|
|
106
|
+
function stripUserConnectCodeFromUrl(locationLike = window.location, historyLike = window.history) {
|
|
107
|
+
if (!locationLike.hash.includes("code=")) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const path = locationLike.hash.startsWith("#")
|
|
111
|
+
? locationLike.hash.slice(1).split("?")[0]
|
|
112
|
+
: locationLike.hash.split("?")[0];
|
|
113
|
+
const nextHash = path === "contacts/connect" ? "#contacts/connect" : "";
|
|
114
|
+
const nextUrl = `${locationLike.pathname}${locationLike.search}${nextHash}`;
|
|
115
|
+
historyLike.replaceState(historyLike.state ?? null, "", nextUrl);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Persist a pending invite in tab-scoped sessionStorage.
|
|
119
|
+
* @param intent Valid intent to store
|
|
120
|
+
* @param storage Storage backend (defaults to sessionStorage)
|
|
121
|
+
*/
|
|
122
|
+
function writeUserConnectPendingIntent(intent, storage = sessionStorage) {
|
|
123
|
+
const payload = intent;
|
|
124
|
+
storage.setItem(exports.USER_CONNECT_INVITE_STORAGE_KEY, JSON.stringify(payload));
|
|
125
|
+
}
|
|
126
|
+
function writeErrorMarker(error, capturedAt, storage) {
|
|
127
|
+
const payload = { error, capturedAt };
|
|
128
|
+
storage.setItem(exports.USER_CONNECT_INVITE_STORAGE_KEY, JSON.stringify(payload));
|
|
129
|
+
}
|
|
130
|
+
function parseStoredPayload(raw) {
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
const marker = parsed;
|
|
134
|
+
if (marker.error === "invalid" || marker.error === "expired") {
|
|
135
|
+
const capturedAt = marker.capturedAt;
|
|
136
|
+
if (typeof capturedAt !== "number" || !Number.isFinite(capturedAt)) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return { error: marker.error, capturedAt };
|
|
140
|
+
}
|
|
141
|
+
if (typeof parsed.code === "string" &&
|
|
142
|
+
typeof parsed.capturedAt === "number" &&
|
|
143
|
+
Number.isFinite(parsed.capturedAt)) {
|
|
144
|
+
return {
|
|
145
|
+
code: parsed.code,
|
|
146
|
+
capturedAt: parsed.capturedAt,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Read a pending invite without consuming it.
|
|
157
|
+
* Expired entries are cleared. Invalid markers remain until consumed/cleared
|
|
158
|
+
* so the connection screen can show recovery UI.
|
|
159
|
+
* @param storage Storage backend
|
|
160
|
+
* @param nowMs Current epoch ms
|
|
161
|
+
* @returns Capture result
|
|
162
|
+
*/
|
|
163
|
+
function readUserConnectPendingIntent(storage = sessionStorage, nowMs = Date.now()) {
|
|
164
|
+
const raw = storage.getItem(exports.USER_CONNECT_INVITE_STORAGE_KEY);
|
|
165
|
+
if (!raw) {
|
|
166
|
+
return { ok: false, reason: "none" };
|
|
167
|
+
}
|
|
168
|
+
const parsed = parseStoredPayload(raw);
|
|
169
|
+
if (!parsed) {
|
|
170
|
+
writeErrorMarker("invalid", nowMs, storage);
|
|
171
|
+
return { ok: false, reason: "invalid" };
|
|
172
|
+
}
|
|
173
|
+
if ("error" in parsed) {
|
|
174
|
+
return { ok: false, reason: parsed.error };
|
|
175
|
+
}
|
|
176
|
+
const normalized = normalizeUserConnectCode(parsed.code);
|
|
177
|
+
if (!normalized) {
|
|
178
|
+
writeErrorMarker("invalid", parsed.capturedAt, storage);
|
|
179
|
+
return { ok: false, reason: "invalid" };
|
|
180
|
+
}
|
|
181
|
+
const intent = {
|
|
182
|
+
code: normalized,
|
|
183
|
+
capturedAt: parsed.capturedAt,
|
|
184
|
+
};
|
|
185
|
+
if (!isUserConnectIntentFresh(intent, nowMs)) {
|
|
186
|
+
writeErrorMarker("expired", parsed.capturedAt, storage);
|
|
187
|
+
return { ok: false, reason: "expired" };
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, intent };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Read and remove a pending invite in one step (one-time consumption).
|
|
193
|
+
* @param storage Storage backend
|
|
194
|
+
* @param nowMs Current epoch ms
|
|
195
|
+
* @returns Capture result for the consumed intent
|
|
196
|
+
*/
|
|
197
|
+
function consumeUserConnectPendingIntent(storage = sessionStorage, nowMs = Date.now()) {
|
|
198
|
+
const result = readUserConnectPendingIntent(storage, nowMs);
|
|
199
|
+
storage.removeItem(exports.USER_CONNECT_INVITE_STORAGE_KEY);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Remove any pending invite from sessionStorage.
|
|
204
|
+
* @param storage Storage backend
|
|
205
|
+
*/
|
|
206
|
+
function clearUserConnectPendingIntent(storage = sessionStorage) {
|
|
207
|
+
storage.removeItem(exports.USER_CONNECT_INVITE_STORAGE_KEY);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Capture an inbound invite from the current location into sessionStorage and
|
|
211
|
+
* strip the code from the address bar. Safe to call multiple times; a second
|
|
212
|
+
* call with no code in the URL leaves existing storage alone.
|
|
213
|
+
* @param options Optional location/history/storage/clock overrides for tests
|
|
214
|
+
* @returns Capture result
|
|
215
|
+
*/
|
|
216
|
+
function captureUserConnectInviteFromLocation(options) {
|
|
217
|
+
const locationLike = options?.locationLike ?? window.location;
|
|
218
|
+
const historyLike = options?.historyLike ?? window.history;
|
|
219
|
+
const storage = options?.storage ?? sessionStorage;
|
|
220
|
+
const nowMs = options?.nowMs ?? Date.now();
|
|
221
|
+
const extracted = extractUserConnectCodeFromFragment(locationLike.hash);
|
|
222
|
+
if (extracted === null) {
|
|
223
|
+
const rawFragment = locationLike.hash.startsWith("#")
|
|
224
|
+
? locationLike.hash.slice(1)
|
|
225
|
+
: locationLike.hash;
|
|
226
|
+
const [path, query = ""] = rawFragment.split("?");
|
|
227
|
+
if (path === "contacts/connect" && new URLSearchParams(query).has("code")) {
|
|
228
|
+
stripUserConnectCodeFromUrl(locationLike, historyLike);
|
|
229
|
+
writeErrorMarker("invalid", nowMs, storage);
|
|
230
|
+
return { ok: false, reason: "invalid" };
|
|
231
|
+
}
|
|
232
|
+
return readUserConnectPendingIntent(storage, nowMs);
|
|
233
|
+
}
|
|
234
|
+
stripUserConnectCodeFromUrl(locationLike, historyLike);
|
|
235
|
+
const normalized = normalizeUserConnectCode(extracted);
|
|
236
|
+
if (!normalized) {
|
|
237
|
+
writeErrorMarker("invalid", nowMs, storage);
|
|
238
|
+
return { ok: false, reason: "invalid" };
|
|
239
|
+
}
|
|
240
|
+
const intent = { code: normalized, capturedAt: nowMs };
|
|
241
|
+
writeUserConnectPendingIntent(intent, storage);
|
|
242
|
+
return { ok: true, intent };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Whether sessionStorage currently holds a fresh, valid pending invite.
|
|
246
|
+
* @param storage Storage backend
|
|
247
|
+
* @param nowMs Current epoch ms
|
|
248
|
+
*/
|
|
249
|
+
function hasPendingUserConnectInvite(storage = sessionStorage, nowMs = Date.now()) {
|
|
250
|
+
return readUserConnectPendingIntent(storage, nowMs).ok;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Share text for the Web Share API / clipboard companion copy.
|
|
254
|
+
* @param inviteUrl Canonical invite URL
|
|
255
|
+
* @returns Human-readable share body
|
|
256
|
+
*/
|
|
257
|
+
function buildUserConnectShareText(inviteUrl) {
|
|
258
|
+
return [
|
|
259
|
+
"Connect with me on Peers:",
|
|
260
|
+
inviteUrl,
|
|
261
|
+
"",
|
|
262
|
+
"Both of us need Peers open. This invite expires in 10 minutes.",
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|