@peers-app/peers-ui 0.23.14 → 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,271 @@
1
+ import {
2
+ getRoleLabel,
3
+ type IAcceptInviteResult,
4
+ type IInvitePreview,
5
+ TrustLevel,
6
+ } from "@peers-app/peers-sdk";
7
+ import { useCallback, useEffect, useState } from "react";
8
+ import { canScanQr, QrScanner } from "../../components/qr-scanner";
9
+ import { mainContentPath } from "../../globals";
10
+ import { consumePendingInvite, inviteCaptured } from "./invite-capture";
11
+ import { inviteTools } from "./invites-ipc";
12
+
13
+ /** Props for {@link AcceptInvite}. */
14
+ export interface AcceptInviteProps {
15
+ /** Token to preview immediately (e.g. from a deep link); otherwise the paste box is shown. */
16
+ initialToken?: string;
17
+ onDone?: () => void;
18
+ }
19
+
20
+ const TRUST_OPTIONS: Array<{ value: TrustLevel; label: string }> = [
21
+ { value: TrustLevel.NewUser, label: "New user (default)" },
22
+ { value: TrustLevel.Known, label: "Known" },
23
+ { value: TrustLevel.Trusted, label: "Trusted" },
24
+ ];
25
+
26
+ function PreviewCard(props: { preview: IInvitePreview }) {
27
+ const { preview } = props;
28
+ const inviter = preview.inviter.displayName || preview.inviter.userId;
29
+ return (
30
+ <div className="card mb-3">
31
+ <div className="card-body">
32
+ <h5 className="card-title mb-2">
33
+ {preview.type === "group" ? `Join ${preview.group?.name}` : `Connect with ${inviter}`}
34
+ </h5>
35
+ <div className="small text-muted">
36
+ From <strong>{inviter}</strong>
37
+ {preview.knownContact ? " (already a contact)" : ""}
38
+ </div>
39
+ {preview.group ? (
40
+ <div className="small text-muted">
41
+ You would join as <strong>{getRoleLabel(preview.group.role)}</strong>
42
+ {preview.group.mode === "open" ? " · joins immediately" : " · the inviter must approve"}
43
+ {preview.group.alreadyMember ? " · you are already a member" : ""}
44
+ </div>
45
+ ) : null}
46
+ <div className={`small ${preview.expired ? "text-danger" : "text-muted"}`}>
47
+ {preview.expired ? "Expired " : "Expires "}
48
+ {new Date(preview.expiresAt).toLocaleString()}
49
+ </div>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function ResultPanel(props: {
56
+ result: IAcceptInviteResult;
57
+ preview: IInvitePreview;
58
+ onDone: () => void;
59
+ }) {
60
+ const { result, preview } = props;
61
+ const accepted = result.status === "accepted";
62
+ const message = accepted
63
+ ? preview.type === "group"
64
+ ? `You joined ${preview.group?.name}.`
65
+ : "Contact added."
66
+ : preview.type === "group"
67
+ ? "Join request sent. You will be added once it is approved."
68
+ : "Request sent. They will see it and confirm.";
69
+ return (
70
+ <div className="text-center">
71
+ <output className={`alert d-block ${accepted ? "alert-success" : "alert-info"}`}>
72
+ {message}
73
+ {result.deliveryState === "queued" ? (
74
+ <div className="small mt-1">Queued until the other person is reachable.</div>
75
+ ) : null}
76
+ </output>
77
+ <div className="d-flex gap-2 justify-content-center">
78
+ {accepted && result.groupId ? (
79
+ <button
80
+ type="button"
81
+ className="btn btn-primary"
82
+ onClick={() => mainContentPath(`groups/${result.groupId}`)}
83
+ >
84
+ Open group
85
+ </button>
86
+ ) : null}
87
+ <button type="button" className="btn btn-outline-secondary" onClick={props.onDone}>
88
+ Done
89
+ </button>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Paste or scan an invite, review what it grants, then accept it. Also consumes a token
97
+ * captured from a link or `peers://` open before this screen mounted.
98
+ */
99
+ export function AcceptInvite(props: AcceptInviteProps) {
100
+ const [text, setText] = useState(props.initialToken ?? "");
101
+ const [scanning, setScanning] = useState(false);
102
+ const [preview, setPreview] = useState<IInvitePreview | undefined>(undefined);
103
+ const [result, setResult] = useState<IAcceptInviteResult | undefined>(undefined);
104
+ const [trustLevel, setTrustLevel] = useState<TrustLevel>(TrustLevel.NewUser);
105
+ const [busy, setBusy] = useState(false);
106
+ const [error, setError] = useState("");
107
+
108
+ const runPreview = useCallback(async (token: string) => {
109
+ setError("");
110
+ setBusy(true);
111
+ try {
112
+ const tools = await inviteTools();
113
+ const response = await tools.previewInvite({ token });
114
+ setPreview(response.preview);
115
+ setText(token);
116
+ } catch (err) {
117
+ setPreview(undefined);
118
+ setError((err as Error).message || "That is not a valid invite.");
119
+ } finally {
120
+ setBusy(false);
121
+ }
122
+ }, []);
123
+
124
+ // Deep-linked tokens: one stashed before mount, or one arriving while mounted.
125
+ useEffect(() => {
126
+ const stashed = props.initialToken ?? consumePendingInvite();
127
+ if (stashed) {
128
+ void runPreview(stashed);
129
+ }
130
+ const sub = inviteCaptured.subscribe(() => {
131
+ const token = consumePendingInvite();
132
+ if (token) {
133
+ void runPreview(token);
134
+ }
135
+ });
136
+ return () => sub.dispose();
137
+ }, [props.initialToken, runPreview]);
138
+
139
+ const accept = async () => {
140
+ setError("");
141
+ setBusy(true);
142
+ try {
143
+ const tools = await inviteTools();
144
+ const response = await tools.acceptInvite({ token: text, trustLevel });
145
+ setResult(response.result);
146
+ } catch (err) {
147
+ setError((err as Error).message || "Could not accept the invite.");
148
+ } finally {
149
+ setBusy(false);
150
+ }
151
+ };
152
+
153
+ const reset = () => {
154
+ setText("");
155
+ setPreview(undefined);
156
+ setResult(undefined);
157
+ setError("");
158
+ };
159
+
160
+ const done = props.onDone ?? reset;
161
+
162
+ if (result && preview) {
163
+ return <ResultPanel result={result} preview={preview} onDone={done} />;
164
+ }
165
+
166
+ if (scanning) {
167
+ return (
168
+ <div className="text-center">
169
+ <QrScanner
170
+ onScan={(scanned) => {
171
+ setScanning(false);
172
+ void runPreview(scanned);
173
+ }}
174
+ onError={(message) => {
175
+ setScanning(false);
176
+ setError(message);
177
+ }}
178
+ />
179
+ <button
180
+ type="button"
181
+ className="btn btn-outline-secondary mt-3"
182
+ onClick={() => setScanning(false)}
183
+ >
184
+ Cancel
185
+ </button>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ return (
191
+ <div style={{ maxWidth: 520, margin: "0 auto" }}>
192
+ {preview ? (
193
+ <>
194
+ <PreviewCard preview={preview} />
195
+ {!preview.knownContact ? (
196
+ <div className="mb-3">
197
+ <label htmlFor="invite-trust" className="form-label small text-muted mb-1">
198
+ Trust level for {preview.inviter.displayName || "this contact"}
199
+ </label>
200
+ <select
201
+ id="invite-trust"
202
+ className="form-select form-select-sm"
203
+ value={trustLevel}
204
+ onChange={(event) => setTrustLevel(Number(event.target.value) as TrustLevel)}
205
+ disabled={busy}
206
+ >
207
+ {TRUST_OPTIONS.map((option) => (
208
+ <option key={option.value} value={option.value}>
209
+ {option.label}
210
+ </option>
211
+ ))}
212
+ </select>
213
+ </div>
214
+ ) : null}
215
+ {error ? <div className="alert alert-danger py-2">{error}</div> : null}
216
+ <div className="d-flex gap-2">
217
+ <button
218
+ type="button"
219
+ className="btn btn-primary"
220
+ disabled={busy || preview.expired || preview.group?.alreadyMember}
221
+ onClick={() => void accept()}
222
+ >
223
+ {busy ? "Working…" : preview.type === "group" ? "Join" : "Accept"}
224
+ </button>
225
+ <button type="button" className="btn btn-outline-secondary" onClick={reset}>
226
+ Back
227
+ </button>
228
+ </div>
229
+ </>
230
+ ) : (
231
+ <form
232
+ onSubmit={(event) => {
233
+ event.preventDefault();
234
+ void runPreview(text);
235
+ }}
236
+ >
237
+ <label htmlFor="invite-text" className="form-label">
238
+ Paste an invite link or code
239
+ </label>
240
+ <textarea
241
+ id="invite-text"
242
+ className="form-control font-monospace mb-2"
243
+ rows={3}
244
+ value={text}
245
+ onChange={(event) => setText(event.target.value)}
246
+ placeholder="https://peers.app/i#…"
247
+ disabled={busy}
248
+ spellCheck={false}
249
+ />
250
+ {error ? <div className="alert alert-danger py-2">{error}</div> : null}
251
+ <div className="d-flex gap-2">
252
+ <button type="submit" className="btn btn-primary" disabled={busy || !text.trim()}>
253
+ {busy ? "Checking…" : "Continue"}
254
+ </button>
255
+ {canScanQr() ? (
256
+ <button
257
+ type="button"
258
+ className="btn btn-outline-primary"
259
+ disabled={busy}
260
+ onClick={() => setScanning(true)}
261
+ >
262
+ <i className="bi bi-qr-code-scan me-1" />
263
+ Scan QR
264
+ </button>
265
+ ) : null}
266
+ </div>
267
+ </form>
268
+ )}
269
+ </div>
270
+ );
271
+ }
@@ -0,0 +1,9 @@
1
+ // Import the screen so it registers its routes.
2
+ import "./invites-screen";
3
+
4
+ export * from "./accept-invite";
5
+ export * from "./invite-capture";
6
+ export * from "./invite-share";
7
+ export * from "./invites-ipc";
8
+ export * from "./invites-screen";
9
+ export * from "./pending-invites-list";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Captures an inbound invite token from the page URL or a desktop `peers://` open-url event
3
+ * and holds it (tab-scoped) until the signed-in UI can route to the Accept screen.
4
+ *
5
+ * Tokens travel only in URL fragments and are stripped from the address bar immediately so
6
+ * startup route restoration never persists them.
7
+ */
8
+
9
+ import { extractInviteTokenText, observable } from "@peers-app/peers-sdk";
10
+
11
+ /** sessionStorage key for a captured token awaiting the Accept screen. */
12
+ export const PENDING_INVITE_STORAGE_KEY = "peers.pendingInviteToken";
13
+
14
+ /** Route of the Accept-invite screen. */
15
+ export const ACCEPT_INVITE_PATH = "invites/accept";
16
+
17
+ /** Fires whenever a new token is captured, so a mounted app can navigate immediately. */
18
+ export const inviteCaptured = observable<string | undefined>(undefined);
19
+
20
+ type IpcRendererLike = {
21
+ on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
22
+ removeAllListeners: (channel: string) => void;
23
+ };
24
+
25
+ /**
26
+ * Pull a token out of a URL fragment or deep link if one is present.
27
+ * Accepts `#invite=<token>`, `https://peers.app/i#<token>`, and `peers://i#<token>`.
28
+ */
29
+ export function inviteTokenFromUrl(url: string): string | undefined {
30
+ const hash = url.indexOf("#");
31
+ if (hash < 0) {
32
+ return undefined;
33
+ }
34
+ const before = url.slice(0, hash);
35
+ const fragment = url.slice(hash + 1);
36
+ const isInviteFragment = fragment.startsWith("invite=");
37
+ const isLandingPage = /^peers:\/\/i\/?$/.test(before) || /^https?:\/\/[^/]+\/i\/?$/.test(before);
38
+ if (!isInviteFragment && !isLandingPage) {
39
+ return undefined;
40
+ }
41
+ try {
42
+ return extractInviteTokenText(url);
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ /** Store a token for the Accept screen and notify listeners. */
49
+ export function stashPendingInvite(token: string, storage: Storage = sessionStorage): void {
50
+ storage.setItem(PENDING_INVITE_STORAGE_KEY, token);
51
+ inviteCaptured(token);
52
+ }
53
+
54
+ /** Read the stored token without consuming it. */
55
+ export function peekPendingInvite(storage: Storage = sessionStorage): string | undefined {
56
+ return storage.getItem(PENDING_INVITE_STORAGE_KEY) ?? undefined;
57
+ }
58
+
59
+ /** Read and clear the stored token. */
60
+ export function consumePendingInvite(storage: Storage = sessionStorage): string | undefined {
61
+ const token = peekPendingInvite(storage);
62
+ storage.removeItem(PENDING_INVITE_STORAGE_KEY);
63
+ return token;
64
+ }
65
+
66
+ /**
67
+ * Capture `#invite=<token>` from the current location, strip it from the URL, and stash it.
68
+ * Safe to call repeatedly; returns the captured token when one was present.
69
+ */
70
+ export function captureInviteFromLocation(
71
+ locationLike: Pick<Location, "href" | "pathname" | "search"> = window.location,
72
+ historyLike: Pick<History, "replaceState" | "state"> = window.history,
73
+ storage: Storage = sessionStorage,
74
+ ): string | undefined {
75
+ const token = inviteTokenFromUrl(locationLike.href);
76
+ if (!token) {
77
+ return undefined;
78
+ }
79
+ historyLike.replaceState(
80
+ historyLike.state ?? null,
81
+ "",
82
+ `${locationLike.pathname}${locationLike.search}`,
83
+ );
84
+ stashPendingInvite(token, storage);
85
+ return token;
86
+ }
87
+
88
+ /**
89
+ * Listen for `peers://` links forwarded by the Electron main process (`peers-open-url`).
90
+ * No-op outside Electron. Returns an unsubscribe function.
91
+ */
92
+ export function listenForOpenUrlInvites(): () => void {
93
+ const ipc = (window as Window & { ipcRenderer?: IpcRendererLike }).ipcRenderer;
94
+ if (!ipc) {
95
+ return () => {};
96
+ }
97
+ ipc.on("peers-open-url", (_event, url) => {
98
+ if (typeof url !== "string") {
99
+ return;
100
+ }
101
+ const token = inviteTokenFromUrl(url);
102
+ if (token) {
103
+ stashPendingInvite(token);
104
+ }
105
+ });
106
+ return () => ipc.removeAllListeners("peers-open-url");
107
+ }
@@ -0,0 +1,104 @@
1
+ import { copyToClipboard, type ICreatedInvite } from "@peers-app/peers-sdk";
2
+ import { useState } from "react";
3
+ import QRCode from "react-qr-code";
4
+
5
+ /** Props for {@link InviteShare}. */
6
+ export interface InviteShareProps {
7
+ invite: ICreatedInvite;
8
+ /** Short line under the heading explaining what accepting grants. */
9
+ description?: string;
10
+ /** Body text used by the native share sheet; defaults to a generic line. */
11
+ shareText?: string;
12
+ onDone?: () => void;
13
+ }
14
+
15
+ function canNativeShare(): boolean {
16
+ return typeof navigator !== "undefined" && typeof navigator.share === "function";
17
+ }
18
+
19
+ /**
20
+ * Presents an issued invite as a QR code plus copyable link and token. The QR encodes the
21
+ * landing-page URL so phone cameras, the in-app scanner, and the desktop handler all work.
22
+ */
23
+ export function InviteShare(props: InviteShareProps) {
24
+ const { invite } = props;
25
+ const [copied, setCopied] = useState<"link" | "token" | undefined>(undefined);
26
+ const expires = new Date(invite.token.expiresAt);
27
+
28
+ const copy = async (what: "link" | "token") => {
29
+ await copyToClipboard(what === "link" ? invite.url : invite.encoded);
30
+ setCopied(what);
31
+ window.setTimeout(() => setCopied(undefined), 1500);
32
+ };
33
+
34
+ const share = async () => {
35
+ try {
36
+ await navigator.share({
37
+ title: "Peers invite",
38
+ text: props.shareText ?? "Join me on Peers:",
39
+ url: invite.url,
40
+ });
41
+ } catch {
42
+ // User dismissed the share sheet; nothing to do.
43
+ }
44
+ };
45
+
46
+ return (
47
+ <div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
48
+ {props.description ? <p className="text-muted">{props.description}</p> : null}
49
+ <div className="d-inline-block bg-white rounded p-3 mb-2">
50
+ <QRCode value={invite.url} size={200} aria-label="Invite QR code" />
51
+ </div>
52
+ <p className="small text-muted mb-3">
53
+ Expires {expires.toLocaleString()}
54
+ {invite.token.uses === "unlimited" ? " · reusable" : " · single use"}
55
+ </p>
56
+ <div className="input-group input-group-sm mb-2">
57
+ <input
58
+ className="form-control font-monospace"
59
+ readOnly
60
+ value={invite.url}
61
+ aria-label="Invite link"
62
+ onFocus={(event) => event.currentTarget.select()}
63
+ />
64
+ <button type="button" className="btn btn-outline-primary" onClick={() => void copy("link")}>
65
+ {copied === "link" ? "Copied" : "Copy link"}
66
+ </button>
67
+ </div>
68
+ <details className="text-start small mb-3">
69
+ <summary className="text-muted" style={{ cursor: "pointer" }}>
70
+ No camera or link? Share the code text instead
71
+ </summary>
72
+ <div className="input-group input-group-sm mt-2">
73
+ <input
74
+ className="form-control font-monospace"
75
+ readOnly
76
+ value={invite.encoded}
77
+ aria-label="Invite code"
78
+ onFocus={(event) => event.currentTarget.select()}
79
+ />
80
+ <button
81
+ type="button"
82
+ className="btn btn-outline-secondary"
83
+ onClick={() => void copy("token")}
84
+ >
85
+ {copied === "token" ? "Copied" : "Copy"}
86
+ </button>
87
+ </div>
88
+ </details>
89
+ <div className="d-flex gap-2 justify-content-center">
90
+ {canNativeShare() ? (
91
+ <button type="button" className="btn btn-primary" onClick={() => void share()}>
92
+ <i className="bi bi-share me-1" />
93
+ Share…
94
+ </button>
95
+ ) : null}
96
+ {props.onDone ? (
97
+ <button type="button" className="btn btn-outline-secondary" onClick={props.onDone}>
98
+ Done
99
+ </button>
100
+ ) : null}
101
+ </div>
102
+ </div>
103
+ );
104
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ createContractConsumer,
3
+ getHostContractTransport,
4
+ getInvitesContractDefinition,
5
+ getUserContext,
6
+ type IContractTransport,
7
+ type IInvitesConsumer,
8
+ subscribeHostContractTransport,
9
+ waitForHostContractTransport,
10
+ } from "@peers-app/peers-sdk";
11
+
12
+ const HOST_TRANSPORT_TIMEOUT_MS = 10_000;
13
+
14
+ let cached: { transport: IContractTransport; consumer: IInvitesConsumer } | undefined;
15
+ let pending: Promise<IInvitesConsumer> | undefined;
16
+
17
+ subscribeHostContractTransport(
18
+ (transport) => {
19
+ if (cached && cached.transport !== transport) {
20
+ cached.consumer.dispose();
21
+ cached = undefined;
22
+ pending = undefined;
23
+ }
24
+ },
25
+ { emitCurrent: false },
26
+ );
27
+
28
+ async function load(transport: IContractTransport): Promise<IInvitesConsumer> {
29
+ const userContext = await getUserContext();
30
+ const consumer = createContractConsumer(getInvitesContractDefinition(), transport, {
31
+ dataContextId: userContext.userId,
32
+ }) as unknown as IInvitesConsumer;
33
+ try {
34
+ await consumer.loadingPromise;
35
+ if (getHostContractTransport() !== transport) {
36
+ consumer.dispose();
37
+ return getInvitesConsumer();
38
+ }
39
+ cached = { transport, consumer };
40
+ return consumer;
41
+ } catch (error) {
42
+ consumer.dispose();
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Return the typed local-host consumer for the System Invites contract.
49
+ *
50
+ * The consumer is cached per host transport and rebuilt if the transport is replaced
51
+ * (e.g. after a socket reconnect).
52
+ */
53
+ export async function getInvitesConsumer(): Promise<IInvitesConsumer> {
54
+ const transport =
55
+ getHostContractTransport() ??
56
+ (await waitForHostContractTransport({ timeoutMs: HOST_TRANSPORT_TIMEOUT_MS }));
57
+ if (cached?.transport === transport) {
58
+ return cached.consumer;
59
+ }
60
+ if (pending) {
61
+ return pending;
62
+ }
63
+ pending = load(transport);
64
+ try {
65
+ return await pending;
66
+ } finally {
67
+ pending = undefined;
68
+ }
69
+ }
70
+
71
+ /** Shorthand for `(await getInvitesConsumer()).tools`. */
72
+ export async function inviteTools(): Promise<IInvitesConsumer["tools"]> {
73
+ return (await getInvitesConsumer()).tools;
74
+ }
@@ -0,0 +1,78 @@
1
+ import { useState } from "react";
2
+ import { registerInternalPeersUI } from "../../ui-router/ui-loader";
3
+ import { AcceptInvite } from "./accept-invite";
4
+ import { ACCEPT_INVITE_PATH, peekPendingInvite } from "./invite-capture";
5
+ import { PendingInvitesList } from "./pending-invites-list";
6
+
7
+ /** Screen props supplied by the router. */
8
+ export interface InvitesScreenProps {
9
+ /** When `true`, the Accept panel starts open (the `invites/accept` route). */
10
+ accept?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Invites hub: accept a pasted/scanned/deep-linked invite and review everything pending.
15
+ */
16
+ export function InvitesScreen(props: InvitesScreenProps) {
17
+ const [accepting, setAccepting] = useState(Boolean(props.accept || peekPendingInvite()));
18
+ return (
19
+ <div className="container-fluid p-3" style={{ maxWidth: 720 }}>
20
+ <div className="d-flex justify-content-between align-items-center mb-3">
21
+ <h4 className="mb-0">
22
+ <i className="bi bi-envelope-paper me-2" />
23
+ Invites
24
+ </h4>
25
+ {!accepting ? (
26
+ <button
27
+ type="button"
28
+ className="btn btn-primary btn-sm"
29
+ onClick={() => setAccepting(true)}
30
+ >
31
+ <i className="bi bi-box-arrow-in-right me-1" />
32
+ Accept an invite
33
+ </button>
34
+ ) : null}
35
+ </div>
36
+
37
+ {accepting ? (
38
+ <div className="card mb-4">
39
+ <div className="card-body">
40
+ <h6 className="card-title">Accept an invite</h6>
41
+ <AcceptInvite onDone={() => setAccepting(false)} />
42
+ </div>
43
+ </div>
44
+ ) : null}
45
+
46
+ <PendingInvitesList title="Waiting on you or others" />
47
+ <details className="mt-3">
48
+ <summary className="small text-muted" style={{ cursor: "pointer" }}>
49
+ Recent history
50
+ </summary>
51
+ <div className="mt-2">
52
+ <PendingInvitesList statuses={["accepted", "declined", "expired", "revoked"]} />
53
+ </div>
54
+ </details>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ registerInternalPeersUI({
60
+ peersUIId: "00invites0screen000000001",
61
+ component: InvitesScreen,
62
+ routes: [
63
+ {
64
+ isMatch: (props, context) => {
65
+ if (context.path === "invites") {
66
+ return true;
67
+ }
68
+ if (context.path === ACCEPT_INVITE_PATH) {
69
+ props.accept = true;
70
+ return true;
71
+ }
72
+ return false;
73
+ },
74
+ uiCategory: "screen",
75
+ priority: 2,
76
+ },
77
+ ],
78
+ });