@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,883 +0,0 @@
1
- /**
2
- * Connect-to-user screen: QR invite, link share, manual code entry, and trust confirmation.
3
- */
4
-
5
- import {
6
- copyToClipboard,
7
- Devices,
8
- formatConnectionCode,
9
- generateConfirmationHash,
10
- generateConnectionCode,
11
- getMe,
12
- getUserContext,
13
- type IConnectedDeviceSummary,
14
- type IUser,
15
- type IUserConnectInfo,
16
- rpcServerCalls,
17
- setUserTrustLevel,
18
- TrustLevel,
19
- USER_CONNECT_IDLE_STATUS,
20
- USER_CONNECT_TTL_MS,
21
- type UserConnectErrorCode,
22
- type UserConnectStatus,
23
- Users,
24
- userConnectCodeAnswer,
25
- userConnectCodeOffer,
26
- userConnectStatus,
27
- } from "@peers-app/peers-sdk";
28
- import { useCallback, useEffect, useRef, useState } from "react";
29
- import QRCode from "react-qr-code";
30
- import { mainContentPath } from "../../globals";
31
- import { useObservable } from "../../hooks";
32
- import { registerInternalPeersUI } from "../../ui-router/ui-loader";
33
- import {
34
- buildUserConnectInviteUrl,
35
- buildUserConnectShareText,
36
- captureUserConnectInviteFromLocation,
37
- clearUserConnectPendingIntent,
38
- normalizeUserConnectCode,
39
- readUserConnectPendingIntent,
40
- } from "./user-connect-invite";
41
-
42
- type ScreenPhase = "offer" | "manual" | "review" | "connecting" | "success" | "error" | "expired";
43
-
44
- interface ConnectionResult {
45
- remoteUser: IUser;
46
- confirmationHash: string;
47
- }
48
-
49
- interface LocalError {
50
- code: UserConnectErrorCode;
51
- detail?: string;
52
- }
53
-
54
- const ERROR_COPY: Record<UserConnectErrorCode, { title: string; body: string }> = {
55
- "invalid-code": {
56
- title: "That invite isn’t valid",
57
- body: "Check the link or code and try again, or create a new invite.",
58
- },
59
- "no-route": {
60
- title: "This device isn’t connected yet",
61
- body: "Connect this device to the internet or another Peers device, then try again.",
62
- },
63
- "not-found-or-expired": {
64
- title: "Invite not found or expired",
65
- body: "Ask the other person to create a new invite, or enter a fresh code.",
66
- },
67
- "remote-not-waiting": {
68
- title: "The other device stopped waiting",
69
- body: "Ask them to open Connect again and create a new invite.",
70
- },
71
- canceled: {
72
- title: "Connection canceled",
73
- body: "You can create a new invite or enter another code whenever you’re ready.",
74
- },
75
- "protocol-error": {
76
- title: "Something went wrong",
77
- body: "Try again in a moment. If it keeps failing, create a new invite.",
78
- },
79
- };
80
-
81
- function errorMessage(error: LocalError): { title: string; body: string } {
82
- const base = ERROR_COPY[error.code];
83
- if (error.code === "protocol-error" && error.detail) {
84
- return { title: base.title, body: base.body };
85
- }
86
- return base;
87
- }
88
-
89
- function formatCountdown(remainingMs: number): string {
90
- const clamped = Math.max(0, remainingMs);
91
- const totalSeconds = Math.ceil(clamped / 1000);
92
- const minutes = Math.floor(totalSeconds / 60);
93
- const seconds = totalSeconds % 60;
94
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
95
- }
96
-
97
- function canShare(): boolean {
98
- return typeof navigator !== "undefined" && typeof navigator.share === "function";
99
- }
100
-
101
- /**
102
- * Polished Connect to user screen. Creating an invite is the default path;
103
- * inbound links open a review state before connecting.
104
- */
105
- export function UserConnect() {
106
- const [phase, setPhase] = useState<ScreenPhase>("offer");
107
- const [connectionCode, setConnectionCode] = useState("");
108
- const [inviteUrl, setInviteUrl] = useState("");
109
- const [expiresAt, setExpiresAt] = useState<number | null>(null);
110
- const [remainingMs, setRemainingMs] = useState(USER_CONNECT_TTL_MS);
111
- const [inputCode, setInputCode] = useState("");
112
- const [reviewCode, setReviewCode] = useState("");
113
- const [result, setResult] = useState<ConnectionResult | null>(null);
114
- const [localError, setLocalError] = useState<LocalError | null>(null);
115
- const [feedback, setFeedback] = useState("");
116
- const [submitting, setSubmitting] = useState(false);
117
- const [hasRoute, setHasRoute] = useState<boolean | null>(null);
118
- const [connectStatus] = useObservable(userConnectStatus);
119
- const [answerCode] = useObservable(userConnectCodeAnswer);
120
- const completedRef = useRef(false);
121
- const offerStartedRef = useRef(false);
122
- const answerWasActiveRef = useRef(false);
123
- const protocolActiveRef = useRef(false);
124
- const ceremonyGenerationRef = useRef(0);
125
-
126
- const clearCeremony = useCallback((options?: { preservePendingIntent?: boolean }) => {
127
- ceremonyGenerationRef.current += 1;
128
- completedRef.current = false;
129
- answerWasActiveRef.current = false;
130
- protocolActiveRef.current = false;
131
- userConnectCodeOffer("");
132
- userConnectCodeAnswer("");
133
- userConnectStatus(USER_CONNECT_IDLE_STATUS);
134
- if (!options?.preservePendingIntent) {
135
- clearUserConnectPendingIntent();
136
- }
137
- }, []);
138
-
139
- const startOffer = useCallback(() => {
140
- completedRef.current = false;
141
- clearCeremony();
142
- const code = generateConnectionCode();
143
- const formatted = formatConnectionCode(code.code);
144
- setConnectionCode(formatted);
145
- setInviteUrl(buildUserConnectInviteUrl(code.code));
146
- setExpiresAt(Date.now() + USER_CONNECT_TTL_MS);
147
- setRemainingMs(USER_CONNECT_TTL_MS);
148
- setLocalError(null);
149
- setResult(null);
150
- setFeedback("");
151
- setSubmitting(false);
152
- setPhase("offer");
153
- userConnectCodeOffer(code.code);
154
- }, [clearCeremony]);
155
-
156
- const showError = useCallback((error: LocalError) => {
157
- answerWasActiveRef.current = false;
158
- protocolActiveRef.current = false;
159
- setLocalError(error);
160
- setSubmitting(false);
161
- setPhase("error");
162
- }, []);
163
-
164
- const applyPendingInvite = useCallback((): boolean => {
165
- const pending = readUserConnectPendingIntent();
166
- if (!pending.ok && pending.reason === "none") {
167
- return false;
168
- }
169
-
170
- clearCeremony({ preservePendingIntent: true });
171
- offerStartedRef.current = true;
172
- setConnectionCode("");
173
- setInviteUrl("");
174
- setExpiresAt(null);
175
- setLocalError(null);
176
- setResult(null);
177
- setFeedback("");
178
- setSubmitting(false);
179
-
180
- if (!pending.ok && pending.reason === "invalid") {
181
- setReviewCode("");
182
- setInputCode("");
183
- setLocalError({ code: "invalid-code" });
184
- setPhase("error");
185
- return true;
186
- }
187
- if (!pending.ok && pending.reason === "expired") {
188
- setReviewCode("");
189
- setInputCode("");
190
- setPhase("expired");
191
- return true;
192
- }
193
-
194
- if (pending.ok) {
195
- const formatted = formatConnectionCode(pending.intent.code);
196
- setReviewCode(formatted);
197
- setInputCode(formatted);
198
- setPhase("review");
199
- }
200
- return true;
201
- }, [clearCeremony]);
202
-
203
- const refreshRouteStatus = useCallback(async () => {
204
- try {
205
- const devices: IConnectedDeviceSummary[] = await rpcServerCalls.listConnectedDevices();
206
- setHasRoute(devices.length > 0);
207
- } catch {
208
- // If the RPC is unavailable, do not block entry — route may appear shortly.
209
- setHasRoute(null);
210
- }
211
- }, []);
212
-
213
- // Capture inbound intent once on mount; otherwise auto-start an offer.
214
- useEffect(() => {
215
- if (applyPendingInvite()) {
216
- return;
217
- }
218
- if (!offerStartedRef.current) {
219
- offerStartedRef.current = true;
220
- startOffer();
221
- }
222
- }, [applyPendingInvite, startOffer]);
223
-
224
- // A second invite can arrive while this screen is already mounted.
225
- useEffect(() => {
226
- const captureInboundInvite = () => {
227
- const path = window.location.hash.slice(1).split("?")[0];
228
- if (path !== "contacts/connect") {
229
- return;
230
- }
231
- const captured = captureUserConnectInviteFromLocation();
232
- if (captured.ok || captured.reason !== "none") {
233
- applyPendingInvite();
234
- }
235
- };
236
- window.addEventListener("hashchange", captureInboundInvite);
237
- return () => window.removeEventListener("hashchange", captureInboundInvite);
238
- }, [applyPendingInvite]);
239
-
240
- useEffect(() => {
241
- void refreshRouteStatus();
242
- }, [refreshRouteStatus]);
243
-
244
- // Countdown while an offer is active.
245
- useEffect(() => {
246
- if (phase !== "offer" || expiresAt === null) {
247
- return;
248
- }
249
- const tick = () => {
250
- const remaining = expiresAt - Date.now();
251
- setRemainingMs(remaining);
252
- if (remaining <= 0) {
253
- clearCeremony();
254
- setPhase("expired");
255
- }
256
- };
257
- tick();
258
- const id = window.setInterval(tick, 1000);
259
- return () => window.clearInterval(id);
260
- }, [phase, expiresAt, clearCeremony]);
261
-
262
- const handleProtocolStatus = useCallback(
263
- async (status: UserConnectStatus | undefined) => {
264
- if (!status || completedRef.current) {
265
- return;
266
- }
267
- if (status.kind === "connecting") {
268
- protocolActiveRef.current = true;
269
- return;
270
- }
271
- if (status.kind === "connected") {
272
- const generation = ceremonyGenerationRef.current;
273
- completedRef.current = true;
274
- try {
275
- const userContext = await getUserContext();
276
- const me = await getMe();
277
- const remoteUser = await Users(userContext.userDataContext).get(status.remoteUserId);
278
- if (generation !== ceremonyGenerationRef.current) {
279
- return;
280
- }
281
- if (!remoteUser) {
282
- showError({ code: "protocol-error", detail: "Could not find connected user" });
283
- return;
284
- }
285
- const remoteDevice = await Devices(userContext.userDataContext).get(
286
- status.remoteDeviceId,
287
- );
288
- if (generation !== ceremonyGenerationRef.current) {
289
- return;
290
- }
291
- if (!remoteDevice) {
292
- showError({
293
- code: "protocol-error",
294
- detail: "Could not find connected device",
295
- });
296
- return;
297
- }
298
- const myInfo: IUserConnectInfo = {
299
- userId: me.userId,
300
- publicKey: me.publicKey,
301
- publicBoxKey: me.publicBoxKey,
302
- deviceId: userContext.deviceId(),
303
- };
304
- const remoteInfo: IUserConnectInfo = {
305
- userId: remoteUser.userId,
306
- publicKey: remoteUser.publicKey,
307
- publicBoxKey: remoteUser.publicBoxKey,
308
- deviceId: remoteDevice.deviceId,
309
- };
310
- setResult({
311
- remoteUser,
312
- confirmationHash: generateConfirmationHash(myInfo, remoteInfo),
313
- });
314
- clearUserConnectPendingIntent();
315
- userConnectCodeOffer("");
316
- userConnectCodeAnswer("");
317
- setSubmitting(false);
318
- setPhase("success");
319
- } catch (err: unknown) {
320
- if (generation !== ceremonyGenerationRef.current) {
321
- return;
322
- }
323
- showError({
324
- code: "protocol-error",
325
- detail: err instanceof Error ? err.message : String(err),
326
- });
327
- }
328
- return;
329
- }
330
- if (status.kind === "error") {
331
- showError({ code: status.code, detail: status.detail });
332
- }
333
- },
334
- [showError],
335
- );
336
-
337
- useEffect(() => {
338
- void handleProtocolStatus(connectStatus as UserConnectStatus | undefined);
339
- }, [connectStatus, handleProtocolStatus]);
340
-
341
- useEffect(() => {
342
- if (
343
- phase !== "connecting" ||
344
- !connectStatus ||
345
- connectStatus.kind !== "idle" ||
346
- (answerCode && !protocolActiveRef.current) ||
347
- (!answerCode && !answerWasActiveRef.current)
348
- ) {
349
- return;
350
- }
351
-
352
- // Terminal handlers publish error/connected immediately after clearing the
353
- // answer. Defer idle recovery one turn so those statuses win.
354
- const timeout = window.setTimeout(() => {
355
- const latestStatus = userConnectStatus();
356
- const latestAnswer = userConnectCodeAnswer();
357
- if (latestStatus.kind !== "idle") {
358
- return;
359
- }
360
- if (latestAnswer) {
361
- if (protocolActiveRef.current) {
362
- clearCeremony();
363
- showError({ code: "canceled" });
364
- }
365
- return;
366
- }
367
- if (answerWasActiveRef.current) {
368
- clearCeremony();
369
- setSubmitting(false);
370
- setPhase("expired");
371
- }
372
- }, 0);
373
- return () => window.clearTimeout(timeout);
374
- }, [answerCode, clearCeremony, connectStatus, phase, showError]);
375
-
376
- useEffect(() => {
377
- return () => {
378
- // Leaving the screen cancels an in-flight offer/answer.
379
- userConnectCodeOffer("");
380
- userConnectCodeAnswer("");
381
- userConnectStatus(USER_CONNECT_IDLE_STATUS);
382
- const path = window.location.hash.slice(1).split("?")[0];
383
- if (path !== "contacts/connect") {
384
- clearUserConnectPendingIntent();
385
- }
386
- };
387
- }, []);
388
-
389
- const handleConfirmReview = useCallback(async () => {
390
- if (submitting) return;
391
- const pending = readUserConnectPendingIntent();
392
- if (!pending.ok) {
393
- if (pending.reason === "invalid" || pending.reason === "expired") {
394
- applyPendingInvite();
395
- } else {
396
- showError({ code: "canceled" });
397
- }
398
- return;
399
- }
400
- const normalized = normalizeUserConnectCode(reviewCode);
401
- if (!normalized || normalized !== pending.intent.code) {
402
- showError({ code: "invalid-code" });
403
- return;
404
- }
405
- ceremonyGenerationRef.current += 1;
406
- completedRef.current = false;
407
- answerWasActiveRef.current = true;
408
- protocolActiveRef.current = false;
409
- setSubmitting(true);
410
- setPhase("connecting");
411
- setLocalError(null);
412
- clearUserConnectPendingIntent();
413
- userConnectStatus(USER_CONNECT_IDLE_STATUS);
414
- userConnectCodeAnswer(normalized);
415
- }, [applyPendingInvite, reviewCode, showError, submitting]);
416
-
417
- const handleManualConnect = useCallback(async () => {
418
- if (submitting) return;
419
- const normalized = normalizeUserConnectCode(inputCode);
420
- if (!normalized) {
421
- setLocalError({ code: "invalid-code" });
422
- return;
423
- }
424
- ceremonyGenerationRef.current += 1;
425
- completedRef.current = false;
426
- answerWasActiveRef.current = true;
427
- protocolActiveRef.current = false;
428
- setSubmitting(true);
429
- setPhase("connecting");
430
- setLocalError(null);
431
- clearUserConnectPendingIntent();
432
- userConnectStatus(USER_CONNECT_IDLE_STATUS);
433
- userConnectCodeAnswer(normalized);
434
- }, [inputCode, submitting]);
435
-
436
- const handleCancel = useCallback(() => {
437
- clearCeremony();
438
- setSubmitting(false);
439
- setLocalError(null);
440
- setResult(null);
441
- mainContentPath("contacts");
442
- }, [clearCeremony]);
443
-
444
- const handleEnterManual = useCallback(() => {
445
- clearCeremony();
446
- setSubmitting(false);
447
- setLocalError(null);
448
- setInputCode("");
449
- setPhase("manual");
450
- }, [clearCeremony]);
451
-
452
- const handleCopyLink = useCallback(async () => {
453
- if (!inviteUrl) return;
454
- const ok = await copyToClipboard(inviteUrl);
455
- setFeedback(ok ? "Link copied" : "Could not copy link");
456
- window.setTimeout(() => setFeedback(""), 2000);
457
- }, [inviteUrl]);
458
-
459
- const handleShareLink = useCallback(async () => {
460
- if (!inviteUrl || !canShare()) {
461
- await handleCopyLink();
462
- return;
463
- }
464
- try {
465
- await navigator.share({
466
- title: "Connect on Peers",
467
- text: buildUserConnectShareText(inviteUrl),
468
- url: inviteUrl,
469
- });
470
- setFeedback("Share sheet opened");
471
- window.setTimeout(() => setFeedback(""), 2000);
472
- } catch (err: unknown) {
473
- // User cancel is neutral.
474
- if (err instanceof DOMException && err.name === "AbortError") {
475
- return;
476
- }
477
- await handleCopyLink();
478
- }
479
- }, [handleCopyLink, inviteUrl]);
480
-
481
- const handleTrustAndView = useCallback(async () => {
482
- if (!result) return;
483
- try {
484
- const userContext = await getUserContext();
485
- await setUserTrustLevel(
486
- result.remoteUser.userId,
487
- TrustLevel.Trusted,
488
- userContext.userDataContext,
489
- );
490
- clearCeremony();
491
- mainContentPath(`contacts/${result.remoteUser.userId}`);
492
- } catch (err: unknown) {
493
- showError({
494
- code: "protocol-error",
495
- detail: err instanceof Error ? err.message : String(err),
496
- });
497
- }
498
- }, [clearCeremony, result, showError]);
499
-
500
- const handleViewWithoutTrust = useCallback(() => {
501
- if (!result) return;
502
- clearCeremony();
503
- mainContentPath(`contacts/${result.remoteUser.userId}`);
504
- }, [clearCeremony, result]);
505
-
506
- const handleCodeInput = (value: string) => {
507
- const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "");
508
- let formatted = "";
509
- for (let i = 0; i < cleaned.length && i < 12; i++) {
510
- if (i === 4 || i === 8) formatted += "-";
511
- formatted += cleaned[i];
512
- }
513
- setInputCode(formatted);
514
- setLocalError(null);
515
- };
516
-
517
- return (
518
- <div className="container-fluid p-3">
519
- <div className="d-flex justify-content-between align-items-center mb-3">
520
- <h4 className="mb-0">
521
- <i className="bi-person-plus-fill me-2" aria-hidden="true" />
522
- Connect to user
523
- </h4>
524
- {phase !== "offer" && phase !== "success" && (
525
- <button type="button" className="btn btn-outline-secondary btn-sm" onClick={handleCancel}>
526
- <i className="bi-arrow-left me-1" aria-hidden="true" />
527
- Back
528
- </button>
529
- )}
530
- </div>
531
-
532
- {hasRoute === false && phase !== "success" && (
533
- <div className="alert alert-warning" role="status">
534
- <div className="d-flex justify-content-between align-items-start gap-2 flex-wrap">
535
- <div>
536
- This device isn’t connected to the internet or another Peers device yet. You can still
537
- prepare an invite; connection will work once a route appears.
538
- </div>
539
- <button
540
- type="button"
541
- className="btn btn-sm btn-outline-warning"
542
- onClick={() => void refreshRouteStatus()}
543
- >
544
- Check again
545
- </button>
546
- </div>
547
- </div>
548
- )}
549
-
550
- <div aria-live="polite" className="visually-hidden">
551
- {feedback}
552
- </div>
553
-
554
- {phase === "offer" && (
555
- <OfferPanel
556
- connectionCode={connectionCode}
557
- inviteUrl={inviteUrl}
558
- remainingMs={remainingMs}
559
- feedback={feedback}
560
- onShare={() => void handleShareLink()}
561
- onCopy={() => void handleCopyLink()}
562
- onEnterManual={handleEnterManual}
563
- onCancel={handleCancel}
564
- />
565
- )}
566
-
567
- {phase === "manual" && (
568
- <ManualEntryPanel
569
- inputCode={inputCode}
570
- localError={localError}
571
- submitting={submitting}
572
- onChange={handleCodeInput}
573
- onConnect={() => void handleManualConnect()}
574
- />
575
- )}
576
-
577
- {phase === "review" && (
578
- <ReviewPanel
579
- reviewCode={reviewCode}
580
- submitting={submitting}
581
- onConfirm={() => void handleConfirmReview()}
582
- onEnterManual={handleEnterManual}
583
- />
584
- )}
585
-
586
- {phase === "connecting" && <ConnectingPanel />}
587
-
588
- {phase === "success" && result && (
589
- <SuccessPanel
590
- result={result}
591
- onTrust={() => void handleTrustAndView()}
592
- onViewWithoutTrust={handleViewWithoutTrust}
593
- />
594
- )}
595
-
596
- {phase === "error" && localError && (
597
- <ErrorPanel error={localError} onNewInvite={startOffer} onEnterCode={handleEnterManual} />
598
- )}
599
-
600
- {phase === "expired" && (
601
- <ExpiredPanel onNewInvite={startOffer} onEnterCode={handleEnterManual} />
602
- )}
603
- </div>
604
- );
605
- }
606
-
607
- function OfferPanel(props: {
608
- connectionCode: string;
609
- inviteUrl: string;
610
- remainingMs: number;
611
- feedback: string;
612
- onShare: () => void;
613
- onCopy: () => void;
614
- onEnterManual: () => void;
615
- onCancel: () => void;
616
- }) {
617
- const shareAvailable = canShare();
618
- return (
619
- <div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
620
- <p className="text-muted mb-3">Ask the other person to scan this with their camera</p>
621
-
622
- <div
623
- className="d-inline-block p-3 mb-3 rounded"
624
- style={{ background: "#ffffff" }}
625
- aria-label="Connection QR code"
626
- >
627
- {props.inviteUrl ? (
628
- <QRCode
629
- value={props.inviteUrl}
630
- size={220}
631
- bgColor="#FFFFFF"
632
- fgColor="#000000"
633
- style={{ height: "auto", maxWidth: "100%", width: "min(70vw, 220px)" }}
634
- viewBox="0 0 256 256"
635
- />
636
- ) : (
637
- <div
638
- className="spinner-border text-primary"
639
- role="status"
640
- aria-label="Preparing invite"
641
- />
642
- )}
643
- </div>
644
-
645
- <div className="mb-2">
646
- <span className="spinner-border spinner-border-sm text-primary me-2" role="status" />
647
- <span className="text-muted">Waiting for connection…</span>
648
- </div>
649
- <p className="text-muted small mb-3" aria-live="polite">
650
- Expires in {formatCountdown(props.remainingMs)}
651
- </p>
652
-
653
- <div className="d-grid gap-2 mb-3">
654
- {shareAvailable ? (
655
- <button type="button" className="btn btn-primary" onClick={props.onShare}>
656
- <i className="bi-share me-2" aria-hidden="true" />
657
- Share link
658
- </button>
659
- ) : null}
660
- <button type="button" className="btn btn-outline-primary" onClick={props.onCopy}>
661
- <i className="bi-clipboard me-2" aria-hidden="true" />
662
- Copy link
663
- </button>
664
- {props.feedback ? <small className="text-success">{props.feedback}</small> : null}
665
- </div>
666
-
667
- <div className="mb-3">
668
- <label className="form-label small text-muted mb-1" htmlFor="user-connect-fallback-code">
669
- Or share this code
670
- </label>
671
- <input
672
- id="user-connect-fallback-code"
673
- type="text"
674
- className="form-control form-control-lg text-center font-monospace"
675
- value={props.connectionCode}
676
- readOnly
677
- aria-label="Connection code"
678
- />
679
- </div>
680
-
681
- <div className="d-flex gap-2 justify-content-center flex-wrap">
682
- <button type="button" className="btn btn-link" onClick={props.onEnterManual}>
683
- Enter a code instead
684
- </button>
685
- <button type="button" className="btn btn-outline-secondary" onClick={props.onCancel}>
686
- Cancel
687
- </button>
688
- </div>
689
- </div>
690
- );
691
- }
692
-
693
- function ManualEntryPanel(props: {
694
- inputCode: string;
695
- localError: LocalError | null;
696
- submitting: boolean;
697
- onChange: (value: string) => void;
698
- onConnect: () => void;
699
- }) {
700
- const ready = props.inputCode.replace(/[^0-9A-Z]/gi, "").length === 12;
701
- return (
702
- <div className="text-center" style={{ maxWidth: 400, margin: "0 auto" }}>
703
- <p className="text-muted mb-3">Enter the connection code shared with you</p>
704
- <input
705
- type="text"
706
- className="form-control form-control-lg text-center font-monospace mb-3"
707
- placeholder="XXXX-YYYY-ZZZZ"
708
- value={props.inputCode}
709
- onChange={(e) => props.onChange(e.target.value)}
710
- maxLength={14}
711
- aria-label="Connection code"
712
- autoComplete="off"
713
- />
714
- {props.localError ? (
715
- <div className="alert alert-danger">{errorMessage(props.localError).body}</div>
716
- ) : null}
717
- <button
718
- type="button"
719
- className="btn btn-primary btn-lg"
720
- onClick={props.onConnect}
721
- disabled={props.submitting || !ready}
722
- >
723
- {props.submitting ? (
724
- <>
725
- <span className="spinner-border spinner-border-sm me-2" role="status" />
726
- Connecting…
727
- </>
728
- ) : (
729
- <>
730
- <i className="bi-link-45deg me-2" aria-hidden="true" />
731
- Connect
732
- </>
733
- )}
734
- </button>
735
- </div>
736
- );
737
- }
738
-
739
- function ReviewPanel(props: {
740
- reviewCode: string;
741
- submitting: boolean;
742
- onConfirm: () => void;
743
- onEnterManual: () => void;
744
- }) {
745
- return (
746
- <div className="text-center" style={{ maxWidth: 400, margin: "0 auto" }}>
747
- <p className="text-muted mb-2">You opened a connection invite</p>
748
- <p className="font-monospace fs-4 mb-3">{props.reviewCode}</p>
749
- <p className="text-muted small mb-4">
750
- Both of you need Peers open. Confirm to connect with the person who shared this invite.
751
- </p>
752
- <button
753
- type="button"
754
- className="btn btn-primary btn-lg mb-2"
755
- onClick={props.onConfirm}
756
- disabled={props.submitting}
757
- >
758
- {props.submitting ? (
759
- <>
760
- <span className="spinner-border spinner-border-sm me-2" role="status" />
761
- Connecting…
762
- </>
763
- ) : (
764
- "Connect"
765
- )}
766
- </button>
767
- <div>
768
- <button type="button" className="btn btn-link" onClick={props.onEnterManual}>
769
- Enter a different code
770
- </button>
771
- </div>
772
- </div>
773
- );
774
- }
775
-
776
- function ConnectingPanel() {
777
- return (
778
- <div className="text-center py-5">
779
- <div className="spinner-border text-primary mb-3" role="status" aria-label="Connecting" />
780
- <p className="text-muted mb-0">Connecting…</p>
781
- </div>
782
- );
783
- }
784
-
785
- function SuccessPanel(props: {
786
- result: ConnectionResult;
787
- onTrust: () => void;
788
- onViewWithoutTrust: () => void;
789
- }) {
790
- const [showDetails, setShowDetails] = useState(false);
791
- return (
792
- <div className="text-center" style={{ maxWidth: 420, margin: "0 auto" }}>
793
- <i className="bi-check-circle-fill text-success display-3 mb-3" aria-hidden="true" />
794
- <h5 className="mb-3">Connected</h5>
795
- <p className="text-muted mb-2">Verify with the other person that you both see:</p>
796
- <h3 className="font-monospace text-primary mb-3" style={{ letterSpacing: "0.15em" }}>
797
- {props.result.confirmationHash}
798
- </h3>
799
- <p className="mb-1">
800
- <span className="text-muted">Name: </span>
801
- {props.result.remoteUser.name || "Unnamed"}
802
- </p>
803
- <button
804
- type="button"
805
- className="btn btn-link btn-sm mb-3"
806
- onClick={() => setShowDetails((v) => !v)}
807
- aria-expanded={showDetails}
808
- >
809
- {showDetails ? "Hide details" : "Show details"}
810
- </button>
811
- {showDetails ? (
812
- <p className="font-monospace small text-muted mb-3">{props.result.remoteUser.userId}</p>
813
- ) : null}
814
- <div className="d-grid gap-2">
815
- <button type="button" className="btn btn-primary" onClick={props.onTrust}>
816
- Trust and view contact
817
- </button>
818
- <button
819
- type="button"
820
- className="btn btn-outline-secondary"
821
- onClick={props.onViewWithoutTrust}
822
- >
823
- View without trusting
824
- </button>
825
- </div>
826
- </div>
827
- );
828
- }
829
-
830
- function ErrorPanel(props: {
831
- error: LocalError;
832
- onNewInvite: () => void;
833
- onEnterCode: () => void;
834
- }) {
835
- const copy = errorMessage(props.error);
836
- return (
837
- <div className="text-center" style={{ maxWidth: 420, margin: "0 auto" }}>
838
- <i className="bi-x-circle-fill text-danger display-3 mb-3" aria-hidden="true" />
839
- <h5 className="mb-2">{copy.title}</h5>
840
- <p className="text-muted mb-4">{copy.body}</p>
841
- <div className="d-flex gap-2 justify-content-center flex-wrap">
842
- <button type="button" className="btn btn-primary" onClick={props.onNewInvite}>
843
- Create a new invite
844
- </button>
845
- <button type="button" className="btn btn-outline-secondary" onClick={props.onEnterCode}>
846
- Enter another code
847
- </button>
848
- </div>
849
- </div>
850
- );
851
- }
852
-
853
- function ExpiredPanel(props: { onNewInvite: () => void; onEnterCode: () => void }) {
854
- return (
855
- <div className="text-center" style={{ maxWidth: 420, margin: "0 auto" }}>
856
- <i className="bi-hourglass-bottom text-warning display-3 mb-3" aria-hidden="true" />
857
- <h5 className="mb-2">Invite expired</h5>
858
- <p className="text-muted mb-4">
859
- Create a new invite or enter a fresh code from the other person.
860
- </p>
861
- <div className="d-flex gap-2 justify-content-center flex-wrap">
862
- <button type="button" className="btn btn-primary" onClick={props.onNewInvite}>
863
- Create a new invite
864
- </button>
865
- <button type="button" className="btn btn-outline-secondary" onClick={props.onEnterCode}>
866
- Enter another code
867
- </button>
868
- </div>
869
- </div>
870
- );
871
- }
872
-
873
- registerInternalPeersUI({
874
- peersUIId: "000user00connect0screen01",
875
- component: UserConnect,
876
- routes: [
877
- {
878
- isMatch: (_props, context) => context.path === "contacts/connect",
879
- uiCategory: "screen",
880
- priority: 3,
881
- },
882
- ],
883
- });