@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
@@ -0,0 +1,224 @@
1
+ import {
2
+ getRoleLabel,
3
+ getUserContext,
4
+ type IInvite,
5
+ type InviteStatus,
6
+ Invites,
7
+ } from "@peers-app/peers-sdk";
8
+ import { useCallback, useEffect, useState } from "react";
9
+ import { inviteTools } from "./invites-ipc";
10
+
11
+ /** Props for {@link PendingInvitesList}. */
12
+ export interface PendingInvitesListProps {
13
+ /** Only show rows of this type. */
14
+ type?: IInvite["type"];
15
+ /** Only show rows for this group. */
16
+ groupId?: string;
17
+ /** Statuses to include; defaults to pending only. */
18
+ statuses?: InviteStatus[];
19
+ /** Hide the section entirely when there is nothing to show. */
20
+ hideWhenEmpty?: boolean;
21
+ title?: string;
22
+ }
23
+
24
+ /** Subscribe to the personal `Invites` table and return rows matching the filter, newest first. */
25
+ export function useInviteRows(filter: {
26
+ type?: IInvite["type"];
27
+ groupId?: string;
28
+ statuses?: InviteStatus[];
29
+ }): IInvite[] | undefined {
30
+ const [rows, setRows] = useState<IInvite[] | undefined>(undefined);
31
+ const statuses = filter.statuses ?? ["pending"];
32
+ const statusKey = statuses.join(",");
33
+
34
+ const refresh = useCallback(async () => {
35
+ const userContext = await getUserContext();
36
+ const table = Invites(userContext.userDataContext);
37
+ const all = await table.list({
38
+ ...(filter.type ? { type: filter.type } : {}),
39
+ ...(filter.groupId ? { groupId: filter.groupId } : {}),
40
+ });
41
+ const wanted = new Set(statusKey.split(","));
42
+ setRows(
43
+ all
44
+ .filter((row) => wanted.has(row.status))
45
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
46
+ );
47
+ }, [filter.type, filter.groupId, statusKey]);
48
+
49
+ useEffect(() => {
50
+ let disposed = false;
51
+ let dispose: (() => void) | undefined;
52
+ void refresh();
53
+ void getUserContext().then((userContext) => {
54
+ if (disposed) {
55
+ return;
56
+ }
57
+ const sub = Invites(userContext.userDataContext).dataChanged.subscribe(() => void refresh());
58
+ dispose = () => {
59
+ sub.unsubscribe();
60
+ };
61
+ });
62
+ return () => {
63
+ disposed = true;
64
+ dispose?.();
65
+ };
66
+ }, [refresh]);
67
+
68
+ return rows;
69
+ }
70
+
71
+ function counterpartLabel(row: IInvite): string {
72
+ return row.counterpart?.displayName || row.counterpart?.userId || "Someone";
73
+ }
74
+
75
+ /** Human summary of what a row is, from the local user's point of view. */
76
+ export function describeInviteRow(row: IInvite): { title: string; detail: string } {
77
+ const who = counterpartLabel(row);
78
+ const role = row.role !== undefined ? getRoleLabel(row.role) : undefined;
79
+ const group = row.groupName ?? "a group";
80
+ if (row.type === "contact") {
81
+ if (row.direction === "in") {
82
+ return { title: `${who} wants to connect`, detail: "Accept to add them as a contact." };
83
+ }
84
+ if (row.counterpart) {
85
+ return { title: `Contact invite to ${who}`, detail: "Waiting for them to accept." };
86
+ }
87
+ return {
88
+ title: row.mode === "profile" ? "Your profile QR" : "Contact invite link",
89
+ detail: row.mode === "profile" ? "Reusable; regenerate to revoke." : "Not used yet.",
90
+ };
91
+ }
92
+ if (row.direction === "in") {
93
+ if (row.mode === "direct") {
94
+ return {
95
+ title: `${who} invited you to ${group}`,
96
+ detail: role ? `You would join as ${role}.` : "",
97
+ };
98
+ }
99
+ return { title: `Joining ${group}`, detail: `Waiting for ${who} to approve.` };
100
+ }
101
+ if (row.mode === "direct") {
102
+ return { title: `Invited ${who} to ${group}`, detail: "Waiting for them to accept." };
103
+ }
104
+ if (row.counterpart) {
105
+ return {
106
+ title: `${who} wants to join ${group}`,
107
+ detail: role ? `Requested as ${role}. Approve or deny.` : "Approve or deny.",
108
+ };
109
+ }
110
+ return {
111
+ title: `${group} invite link`,
112
+ detail: `${row.mode === "open" ? "Open" : "Approval required"}${role ? ` · ${role}` : ""}`,
113
+ };
114
+ }
115
+
116
+ type RowAction = "accept" | "decline" | "approve" | "deny" | "revoke";
117
+
118
+ function actionsFor(row: IInvite): RowAction[] {
119
+ if (row.status !== "pending") {
120
+ return [];
121
+ }
122
+ if (row.direction === "in") {
123
+ // Inbound rows I can act on: contact requests and direct group invites.
124
+ if (row.type === "contact" || row.mode === "direct") {
125
+ return ["accept", "decline"];
126
+ }
127
+ return []; // my own outstanding join request; nothing to do but wait
128
+ }
129
+ if (row.counterpart && row.type === "group" && row.mode !== "direct") {
130
+ return ["approve", "deny"];
131
+ }
132
+ return ["revoke"];
133
+ }
134
+
135
+ const ACTION_LABELS: Record<RowAction, { text: string; className: string }> = {
136
+ accept: { text: "Accept", className: "btn-primary" },
137
+ decline: { text: "Decline", className: "btn-outline-secondary" },
138
+ approve: { text: "Approve", className: "btn-primary" },
139
+ deny: { text: "Deny", className: "btn-outline-secondary" },
140
+ revoke: { text: "Revoke", className: "btn-outline-danger" },
141
+ };
142
+
143
+ function InviteRow({ row }: { row: IInvite }) {
144
+ const [busy, setBusy] = useState<RowAction | undefined>(undefined);
145
+ const [error, setError] = useState("");
146
+ const { title, detail } = describeInviteRow(row);
147
+
148
+ const run = async (action: RowAction) => {
149
+ setBusy(action);
150
+ setError("");
151
+ try {
152
+ const tools = await inviteTools();
153
+ const input = { inviteId: row.inviteId };
154
+ if (action === "accept") await tools.acceptInvite(input);
155
+ else if (action === "decline") await tools.declineInvite(input);
156
+ else if (action === "approve") await tools.approveJoinRequest(input);
157
+ else if (action === "deny") await tools.denyJoinRequest(input);
158
+ else await tools.revokeInvite(input);
159
+ } catch (err) {
160
+ setError((err as Error).message || "Could not update the invite.");
161
+ } finally {
162
+ setBusy(undefined);
163
+ }
164
+ };
165
+
166
+ return (
167
+ <div className="list-group-item">
168
+ <div className="d-flex justify-content-between align-items-center gap-2 flex-wrap">
169
+ <div className="flex-grow-1">
170
+ <div className="fw-semibold">{title}</div>
171
+ {detail ? <small className="text-muted d-block">{detail}</small> : null}
172
+ {row.deliveryState === "queued" ? (
173
+ <small className="text-warning d-block">Queued until they are reachable</small>
174
+ ) : null}
175
+ {row.status !== "pending" ? (
176
+ <small className="text-muted d-block text-capitalize">{row.status}</small>
177
+ ) : null}
178
+ </div>
179
+ <div className="d-flex gap-1">
180
+ {actionsFor(row).map((action) => (
181
+ <button
182
+ key={action}
183
+ type="button"
184
+ className={`btn btn-sm ${ACTION_LABELS[action].className}`}
185
+ disabled={busy !== undefined}
186
+ onClick={() => void run(action)}
187
+ >
188
+ {busy === action ? "…" : ACTION_LABELS[action].text}
189
+ </button>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ {error ? <div className="alert alert-danger py-1 px-2 small mt-2 mb-0">{error}</div> : null}
194
+ </div>
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Live list of invite rows with the actions each one supports. Subscribes to the `Invites`
200
+ * table directly, so it updates when replies arrive on any of the user's devices.
201
+ */
202
+ export function PendingInvitesList(props: PendingInvitesListProps) {
203
+ const rows = useInviteRows(props);
204
+ if (!rows) {
205
+ return null;
206
+ }
207
+ if (rows.length === 0 && props.hideWhenEmpty) {
208
+ return null;
209
+ }
210
+ return (
211
+ <section className="mb-3">
212
+ {props.title ? <h6 className="mb-2">{props.title}</h6> : null}
213
+ {rows.length === 0 ? (
214
+ <p className="text-muted small mb-0">No pending invites.</p>
215
+ ) : (
216
+ <div className="list-group">
217
+ {rows.map((row) => (
218
+ <InviteRow key={row.inviteId} row={row} />
219
+ ))}
220
+ </div>
221
+ )}
222
+ </section>
223
+ );
224
+ }
@@ -258,8 +258,8 @@ const AddDeviceSection: React.FC = () => {
258
258
  <section className="mt-4 pt-3 border-top">
259
259
  <h6>Add another device</h6>
260
260
  <p className="small text-muted">
261
- Create a five-minute invitation, compare the same number on both devices, and approve on
262
- each one before account access is transferred.
261
+ On the new device choose “Sign in from another device”, then type the code it shows here and
262
+ approve. Account access is transferred only after you approve.
263
263
  </p>
264
264
  {open ? (
265
265
  <SourceDevicePairing onClose={() => setOpen(false)} />
@@ -7,13 +7,9 @@ import {
7
7
  rpcServerCalls,
8
8
  signMessageWithSecretKey,
9
9
  } from "@peers-app/peers-sdk";
10
- import { useEffect, useState } from "react";
10
+ import { useState } from "react";
11
11
  import { useObservable } from "../hooks";
12
- import {
13
- consumePendingDevicePairingInvite,
14
- DEVICE_PAIRING_INVITE_CAPTURED_EVENT,
15
- DestinationDevicePairing,
16
- } from "./device-pairing";
12
+ import { DestinationDevicePairing } from "./device-pairing";
17
13
  import { colorMode } from "./settings/color-mode-dropdown";
18
14
 
19
15
  type SetupStep = "select-user-type" | "complete";
@@ -21,10 +17,7 @@ type SetupStep = "select-user-type" | "complete";
21
17
  export const SetupUser = () => {
22
18
  const [_colorMode] = useObservable(colorMode);
23
19
  const [step, _setStep] = useState<SetupStep>("select-user-type");
24
- const [pairingInvitation, setPairingInvitation] = useState<string | undefined>(() =>
25
- consumePendingDevicePairingInvite(),
26
- );
27
- const [showPairing, setShowPairing] = useState(() => Boolean(pairingInvitation));
20
+ const [showPairing, setShowPairing] = useState(false);
28
21
  const [isExistingUser, setIsExistingUser] = useState<boolean | null>(null);
29
22
  const [userId, setUserId] = useState("");
30
23
  const [secretKey, setSecretKey] = useState("");
@@ -35,17 +28,6 @@ export const SetupUser = () => {
35
28
 
36
29
  const isDark = _colorMode === "dark";
37
30
 
38
- useEffect(() => {
39
- const capture = (event: Event) => {
40
- const invitation = (event as CustomEvent<string>).detail;
41
- setPairingInvitation(invitation);
42
- consumePendingDevicePairingInvite();
43
- setShowPairing(true);
44
- };
45
- window.addEventListener(DEVICE_PAIRING_INVITE_CAPTURED_EVENT, capture);
46
- return () => window.removeEventListener(DEVICE_PAIRING_INVITE_CAPTURED_EVENT, capture);
47
- }, []);
48
-
49
31
  const handleNewUser = async () => {
50
32
  setError("");
51
33
  setIsValidating(true);
@@ -120,14 +102,7 @@ export const SetupUser = () => {
120
102
  }}
121
103
  >
122
104
  <div className="card-body p-4 p-md-5">
123
- <DestinationDevicePairing
124
- initialInvitation={pairingInvitation}
125
- onInvitationConsumed={() => setPairingInvitation(undefined)}
126
- onBack={() => {
127
- setPairingInvitation(undefined);
128
- setShowPairing(false);
129
- }}
130
- />
105
+ <DestinationDevicePairing onBack={() => setShowPairing(false)} />
131
106
  </div>
132
107
  </div>
133
108
  </div>
@@ -165,21 +140,8 @@ export const SetupUser = () => {
165
140
  <div className="d-flex align-items-center justify-content-center">
166
141
  <i className="bi bi-person-plus fs-4 me-2" />
167
142
  <div className="text-start">
168
- <div className="fw-semibold">I'm a New User</div>
169
- <small className="opacity-75">Create a new account</small>
170
- </div>
171
- </div>
172
- </button>
173
-
174
- <button
175
- className={`btn btn-lg py-3 ${isDark ? "btn-outline-light" : "btn-outline-primary"}`}
176
- onClick={() => setIsExistingUser(true)}
177
- >
178
- <div className="d-flex align-items-center justify-content-center">
179
- <i className="bi bi-box-arrow-in-right fs-4 me-2" />
180
- <div className="text-start">
181
- <div className="fw-semibold">I'm an Existing User</div>
182
- <small className="opacity-75">Sign in with your credentials</small>
143
+ <div className="fw-semibold">New account</div>
144
+ <small className="opacity-75">Create a new account on this device</small>
183
145
  </div>
184
146
  </div>
185
147
  </button>
@@ -191,11 +153,30 @@ export const SetupUser = () => {
191
153
  <div className="d-flex align-items-center justify-content-center">
192
154
  <i className="bi bi-phone fs-4 me-2" />
193
155
  <div className="text-start">
194
- <div className="fw-semibold">Use Another Device</div>
195
- <small className="opacity-75">Sign in with a device already on Peers</small>
156
+ <div className="fw-semibold">Sign in from another device</div>
157
+ <small className="opacity-75">
158
+ Show a code to a device that is already signed in
159
+ </small>
196
160
  </div>
197
161
  </div>
198
162
  </button>
163
+
164
+ <details className="text-center mt-2">
165
+ <summary
166
+ className={`small ${isDark ? "text-light opacity-75" : "text-muted"}`}
167
+ style={{ cursor: "pointer" }}
168
+ >
169
+ Advanced
170
+ </summary>
171
+ <button
172
+ type="button"
173
+ className={`btn btn-sm mt-2 ${isDark ? "btn-outline-light" : "btn-outline-secondary"}`}
174
+ onClick={() => setIsExistingUser(true)}
175
+ >
176
+ <i className="bi bi-key me-1" />
177
+ Enter a user ID and secret key
178
+ </button>
179
+ </details>
199
180
  </div>
200
181
  </div>
201
182
  </div>
@@ -291,7 +272,8 @@ export const SetupUser = () => {
291
272
  <i className="bi bi-box-arrow-in-right text-primary" />
292
273
  </h3>
293
274
  <p className={isDark ? "text-light opacity-75" : "text-muted"}>
294
- Enter your existing credentials
275
+ Paste your user ID and secret key. If you have another signed-in device, “Sign in
276
+ from another device” is easier and safer.
295
277
  </p>
296
278
  </div>
297
279
 
@@ -7,7 +7,7 @@ export { consoleLogsApp } from "./console-logs.app";
7
7
  export { contactsApp } from "./contacts.app";
8
8
  export { dataExplorerApp } from "./data-explorer.app";
9
9
  export { groupsApp } from "./groups.app";
10
- export { joinGroupApp } from "./join-group.app";
10
+ export { invitesApp } from "./invites.app";
11
11
  export { mobileSettingsApp } from "./mobile-settings.app";
12
12
  export { networkViewerApp } from "./network-viewer.app";
13
13
  export { packagesApp } from "./packages.app";
@@ -26,7 +26,7 @@ import { consoleLogsApp } from "./console-logs.app";
26
26
  import { contactsApp } from "./contacts.app";
27
27
  import { dataExplorerApp } from "./data-explorer.app";
28
28
  import { groupsApp } from "./groups.app";
29
- import { joinGroupApp } from "./join-group.app";
29
+ import { invitesApp } from "./invites.app";
30
30
  import { mobileSettingsApp } from "./mobile-settings.app";
31
31
  import { networkViewerApp } from "./network-viewer.app";
32
32
  import { packagesApp } from "./packages.app";
@@ -64,7 +64,7 @@ export const systemApps: IAppNav[] = [
64
64
  packagesApp,
65
65
  contactsApp,
66
66
  groupsApp,
67
- joinGroupApp,
67
+ invitesApp,
68
68
 
69
69
  // User & Settings Apps
70
70
  accountApp,
@@ -0,0 +1,8 @@
1
+ import type { IAppNav } from "@peers-app/peers-sdk";
2
+
3
+ export const invitesApp: IAppNav = {
4
+ name: "Invites",
5
+ displayName: "Invites",
6
+ iconClassName: "bi-envelope-paper",
7
+ navigationPath: "invites",
8
+ };
@@ -1,118 +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
- /** 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;