@peers-app/peers-ui 0.20.4 → 0.20.6

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.
@@ -1,77 +1,300 @@
1
+ /**
2
+ * Connect-to-user screen: QR invite, link share, manual code entry, and trust confirmation.
3
+ */
4
+
1
5
  import {
6
+ copyToClipboard,
2
7
  Devices,
3
8
  formatConnectionCode,
4
9
  generateConfirmationHash,
5
10
  generateConnectionCode,
6
11
  getMe,
7
12
  getUserContext,
13
+ type IConnectedDeviceSummary,
8
14
  type IUser,
9
15
  type IUserConnectInfo,
16
+ rpcServerCalls,
10
17
  setUserTrustLevel,
11
18
  TrustLevel,
19
+ USER_CONNECT_IDLE_STATUS,
20
+ USER_CONNECT_TTL_MS,
21
+ type UserConnectErrorCode,
22
+ type UserConnectStatus,
12
23
  Users,
13
24
  userConnectCodeAnswer,
14
25
  userConnectCodeOffer,
15
26
  userConnectStatus,
16
27
  } from "@peers-app/peers-sdk";
17
- import { useCallback, useEffect, useState } from "react";
28
+ import { useCallback, useEffect, useRef, useState } from "react";
29
+ import QRCode from "react-qr-code";
18
30
  import { mainContentPath } from "../../globals";
19
31
  import { useObservable } from "../../hooks";
20
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";
21
41
 
22
- type ConnectionMode = "select" | "initiate" | "respond";
23
- type ConnectionStatus = "idle" | "waiting" | "success" | "error";
42
+ type ScreenPhase = "offer" | "manual" | "review" | "connecting" | "success" | "error" | "expired";
24
43
 
25
44
  interface ConnectionResult {
26
45
  remoteUser: IUser;
27
46
  confirmationHash: string;
28
47
  }
29
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
+ */
30
105
  export function UserConnect() {
31
- const [mode, setMode] = useState<ConnectionMode>("select");
32
- const [status, setStatus] = useState<ConnectionStatus>("idle");
33
- const [connectionCode, setConnectionCode] = useState<string>("");
34
- const [inputCode, setInputCode] = useState<string>("");
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("");
35
113
  const [result, setResult] = useState<ConnectionResult | null>(null);
36
- const [error, setError] = useState<string>("");
37
- const [copied, setCopied] = useState(false);
38
-
39
- // Subscribe to userConnectStatus from the device layer
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);
40
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]);
41
223
 
42
- const handleConnectStatusChange = useCallback(
43
- async (connectStatusValue: string) => {
44
- if (!connectStatusValue || status === "success") return;
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]);
45
239
 
46
- if (connectStatusValue.startsWith("Error:")) {
47
- // Error status
48
- setError(connectStatusValue.replace("Error: ", ""));
49
- setStatus("error");
50
- } else if (connectStatusValue.length > 0) {
51
- // Success - connectStatus is the remote userId
52
- const remoteUserId = connectStatusValue;
240
+ useEffect(() => {
241
+ void refreshRouteStatus();
242
+ }, [refreshRouteStatus]);
53
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;
54
274
  try {
55
275
  const userContext = await getUserContext();
56
276
  const me = await getMe();
57
- const remoteUser = await Users(userContext.userDataContext).get(remoteUserId);
58
-
277
+ const remoteUser = await Users(userContext.userDataContext).get(status.remoteUserId);
278
+ if (generation !== ceremonyGenerationRef.current) {
279
+ return;
280
+ }
59
281
  if (!remoteUser) {
60
- setError("Could not find connected user");
61
- setStatus("error");
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) {
62
289
  return;
63
290
  }
64
-
65
- const remoteDevice = await Devices(userContext.userDataContext).findOne({
66
- userId: remoteUserId,
67
- });
68
291
  if (!remoteDevice) {
69
- setError("Could not find connected device but connection was successful");
70
- setStatus("error");
292
+ showError({
293
+ code: "protocol-error",
294
+ detail: "Could not find connected device",
295
+ });
71
296
  return;
72
297
  }
73
-
74
- // Build user connect info for confirmation hash
75
298
  const myInfo: IUserConnectInfo = {
76
299
  userId: me.userId,
77
300
  publicKey: me.publicKey,
@@ -84,363 +307,565 @@ export function UserConnect() {
84
307
  publicBoxKey: remoteUser.publicBoxKey,
85
308
  deviceId: remoteDevice.deviceId,
86
309
  };
87
-
88
- const confirmationHash = generateConfirmationHash(myInfo, remoteInfo);
89
-
90
- setResult({ remoteUser, confirmationHash });
91
- setStatus("success");
92
-
93
- // Clear the codes
310
+ setResult({
311
+ remoteUser,
312
+ confirmationHash: generateConfirmationHash(myInfo, remoteInfo),
313
+ });
314
+ clearUserConnectPendingIntent();
94
315
  userConnectCodeOffer("");
95
316
  userConnectCodeAnswer("");
317
+ setSubmitting(false);
318
+ setPhase("success");
96
319
  } catch (err: unknown) {
97
- setError(err instanceof Error ? err.message : String(err));
98
- setStatus("error");
320
+ if (generation !== ceremonyGenerationRef.current) {
321
+ return;
322
+ }
323
+ showError({
324
+ code: "protocol-error",
325
+ detail: err instanceof Error ? err.message : String(err),
326
+ });
99
327
  }
328
+ return;
329
+ }
330
+ if (status.kind === "error") {
331
+ showError({ code: status.code, detail: status.detail });
100
332
  }
101
333
  },
102
- [status],
334
+ [showError],
103
335
  );
104
336
 
105
- // Also set up a direct subscription after loading is complete
106
337
  useEffect(() => {
107
- let disposed = false;
108
- let subscription: { dispose: () => void } | undefined;
109
-
110
- userConnectStatus.loadingPromise.then(() => {
111
- if (disposed) return;
112
- subscription = userConnectStatus.subscribe(() => {
113
- // Force a re-render by checking the current value
114
- const currentStatus = userConnectStatus();
115
- if (currentStatus && typeof currentStatus === "string") {
116
- handleConnectStatusChange(currentStatus);
117
- }
118
- });
119
- });
120
-
121
- return () => {
122
- disposed = true;
123
- subscription?.dispose();
124
- };
125
- }, [handleConnectStatusChange]);
338
+ void handleProtocolStatus(connectStatus as UserConnectStatus | undefined);
339
+ }, [connectStatus, handleProtocolStatus]);
126
340
 
127
- // React to userConnectStatus changes from useObservable
128
341
  useEffect(() => {
129
- if (connectStatus && typeof connectStatus === "string") {
130
- handleConnectStatusChange(connectStatus);
342
+ if (
343
+ phase !== "connecting" ||
344
+ !connectStatus ||
345
+ connectStatus.kind !== "idle" ||
346
+ (answerCode && !protocolActiveRef.current) ||
347
+ (!answerCode && !answerWasActiveRef.current)
348
+ ) {
349
+ return;
131
350
  }
132
- }, [connectStatus, handleConnectStatusChange]);
133
351
 
134
- // Clean up on unmount
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
+
135
376
  useEffect(() => {
136
377
  return () => {
137
- if (mode === "initiate" && status === "waiting") {
138
- userConnectCodeOffer("");
139
- userConnectCodeAnswer("");
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();
140
385
  }
141
386
  };
142
- }, [mode, status]);
143
-
144
- const handleInitiate = useCallback(async () => {
145
- setMode("initiate");
146
- setStatus("waiting");
147
- setError("");
148
- userConnectStatus(""); // Clear any previous status
149
-
150
- // Generate the connection code
151
- const code = generateConnectionCode();
152
- userConnectCodeOffer(code.code);
153
- const formattedCode = formatConnectionCode(code.code);
154
- setConnectionCode(formattedCode);
155
387
  }, []);
156
388
 
157
- const handleRespond = useCallback(async () => {
158
- if (inputCode.replace(/[^0-9A-Za-z]/g, "").length !== 12) {
159
- setError("Please enter a valid 12-character connection code");
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
+ }
160
398
  return;
161
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]);
162
443
 
163
- setStatus("waiting");
164
- setError("");
165
- userConnectStatus(""); // Clear any previous status
166
- userConnectCodeAnswer(inputCode);
167
- }, [inputCode]);
168
-
169
- const handleCancel = useCallback(async () => {
170
- userConnectCodeOffer("");
171
- userConnectCodeAnswer("");
172
- userConnectStatus("");
173
- setMode("select");
174
- setStatus("idle");
175
- setConnectionCode("");
176
- setError("");
177
- }, []);
178
-
179
- const handleReset = useCallback(() => {
180
- userConnectCodeOffer("");
181
- userConnectCodeAnswer("");
182
- userConnectStatus("");
183
- setMode("select");
184
- setStatus("idle");
185
- setConnectionCode("");
444
+ const handleEnterManual = useCallback(() => {
445
+ clearCeremony();
446
+ setSubmitting(false);
447
+ setLocalError(null);
186
448
  setInputCode("");
187
- setResult(null);
188
- setError("");
189
- setCopied(false);
190
- }, []);
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]);
191
480
 
192
- const handleSaveContact = useCallback(async () => {
481
+ const handleTrustAndView = useCallback(async () => {
193
482
  if (!result) return;
194
-
195
483
  try {
196
484
  const userContext = await getUserContext();
197
-
198
- // Contact is already saved by the device layer, just set trust level
199
485
  await setUserTrustLevel(
200
486
  result.remoteUser.userId,
201
487
  TrustLevel.Trusted,
202
488
  userContext.userDataContext,
203
489
  );
204
-
205
- // Navigate to contact details
490
+ clearCeremony();
206
491
  mainContentPath(`contacts/${result.remoteUser.userId}`);
207
492
  } catch (err: unknown) {
208
- setError(err instanceof Error ? err.message : String(err));
493
+ showError({
494
+ code: "protocol-error",
495
+ detail: err instanceof Error ? err.message : String(err),
496
+ });
209
497
  }
210
- }, [result]);
498
+ }, [clearCeremony, result, showError]);
211
499
 
212
- // Copy connection code to clipboard
213
- const handleCopyCode = useCallback(async () => {
214
- if (!connectionCode) return;
215
- try {
216
- await navigator.clipboard.writeText(connectionCode);
217
- setCopied(true);
218
- setTimeout(() => setCopied(false), 2000);
219
- } catch (_err) {
220
- // Fallback for older browsers
221
- const textArea = document.createElement("textarea");
222
- textArea.value = connectionCode;
223
- document.body.appendChild(textArea);
224
- textArea.select();
225
- document.execCommand("copy");
226
- document.body.removeChild(textArea);
227
- setCopied(true);
228
- setTimeout(() => setCopied(false), 2000);
229
- }
230
- }, [connectionCode]);
500
+ const handleViewWithoutTrust = useCallback(() => {
501
+ if (!result) return;
502
+ clearCeremony();
503
+ mainContentPath(`contacts/${result.remoteUser.userId}`);
504
+ }, [clearCeremony, result]);
231
505
 
232
- // Format input code as user types
233
506
  const handleCodeInput = (value: string) => {
234
- // Remove non-alphanumeric characters
235
507
  const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "");
236
-
237
- // Format as XXXX-YYYY-ZZZZ
238
508
  let formatted = "";
239
509
  for (let i = 0; i < cleaned.length && i < 12; i++) {
240
510
  if (i === 4 || i === 8) formatted += "-";
241
511
  formatted += cleaned[i];
242
512
  }
243
-
244
513
  setInputCode(formatted);
514
+ setLocalError(null);
245
515
  };
246
516
 
247
517
  return (
248
518
  <div className="container-fluid p-3">
249
- <div className="d-flex justify-content-between align-items-center mb-4">
250
- <h4>
251
- <i className="bi-person-plus-fill me-2" />
252
- Connect to New User
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
253
523
  </h4>
254
- {mode !== "select" && (
255
- <button className="btn btn-outline-secondary btn-sm" onClick={handleReset}>
256
- <i className="bi-arrow-left me-1" />
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" />
257
527
  Back
258
528
  </button>
259
529
  )}
260
530
  </div>
261
531
 
262
- {/* Mode Selection */}
263
- {mode === "select" && (
264
- <div className="row g-3">
265
- <div className="col-md-6">
266
- <div
267
- className="card h-100 border-primary"
268
- style={{ cursor: "pointer" }}
269
- onClick={handleInitiate}
270
- >
271
- <div className="card-body text-center p-4">
272
- <i className="bi-qr-code display-4 text-primary mb-3" />
273
- <h5 className="card-title">Create Connection Code</h5>
274
- <p className="card-text text-muted">
275
- Generate a code to share with someone who wants to connect with you
276
- </p>
277
- </div>
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.
278
538
  </div>
279
- </div>
280
- <div className="col-md-6">
281
- <div
282
- className="card h-100 border-success"
283
- style={{ cursor: "pointer" }}
284
- onClick={() => setMode("respond")}
539
+ <button
540
+ type="button"
541
+ className="btn btn-sm btn-outline-warning"
542
+ onClick={() => void refreshRouteStatus()}
285
543
  >
286
- <div className="card-body text-center p-4">
287
- <i className="bi-keyboard display-4 text-success mb-3" />
288
- <h5 className="card-title">Enter Connection Code</h5>
289
- <p className="card-text text-muted">
290
- Enter a code that someone shared with you to connect
291
- </p>
292
- </div>
293
- </div>
544
+ Check again
545
+ </button>
294
546
  </div>
295
547
  </div>
296
548
  )}
297
549
 
298
- {/* Initiate Mode - Show Generated Code */}
299
- {mode === "initiate" && status === "waiting" && (
300
- <div className="text-center">
301
- <div className="mb-4">
302
- <p className="text-muted">Share this code with the person you want to connect with:</p>
303
- </div>
304
-
305
- <div className="mb-4" style={{ maxWidth: "400px", margin: "0 auto" }}>
306
- <div className="input-group">
307
- <input
308
- type="text"
309
- className="form-control form-control-lg text-center font-monospace"
310
- value={connectionCode || "XXXX-YYYY-ZZZZ"}
311
- readOnly
312
- style={{ letterSpacing: "0.15em", fontSize: "1.5rem" }}
313
- />
314
- <button
315
- className="btn btn-outline-primary"
316
- onClick={handleCopyCode}
317
- title="Copy to clipboard"
318
- >
319
- <i className={copied ? "bi-check-lg" : "bi-clipboard"} />
320
- </button>
321
- </div>
322
- {copied && <small className="text-success mt-1 d-block">Copied!</small>}
323
- </div>
550
+ <div aria-live="polite" className="visually-hidden">
551
+ {feedback}
552
+ </div>
324
553
 
325
- <div className="mb-4">
326
- <div className="spinner-border spinner-border-sm text-primary me-2" role="status" />
327
- <span className="text-muted">Waiting for connection...</span>
328
- </div>
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
+ )}
329
566
 
330
- <p className="text-muted small">This code will expire in 10 minutes</p>
567
+ {phase === "manual" && (
568
+ <ManualEntryPanel
569
+ inputCode={inputCode}
570
+ localError={localError}
571
+ submitting={submitting}
572
+ onChange={handleCodeInput}
573
+ onConnect={() => void handleManualConnect()}
574
+ />
575
+ )}
331
576
 
332
- <button className="btn btn-outline-secondary" onClick={handleCancel}>
333
- Cancel
334
- </button>
335
- </div>
577
+ {phase === "review" && (
578
+ <ReviewPanel
579
+ reviewCode={reviewCode}
580
+ submitting={submitting}
581
+ onConfirm={() => void handleConfirmReview()}
582
+ onEnterManual={handleEnterManual}
583
+ />
336
584
  )}
337
585
 
338
- {/* Respond Mode - Enter Code */}
339
- {mode === "respond" && status !== "success" && (
340
- <div className="text-center">
341
- <div className="mb-4">
342
- <p className="text-muted">Enter the connection code shared with you:</p>
343
- </div>
586
+ {phase === "connecting" && <ConnectingPanel />}
344
587
 
345
- <div className="mb-4" style={{ maxWidth: "400px", margin: "0 auto" }}>
346
- <input
347
- type="text"
348
- className="form-control form-control-lg text-center font-monospace"
349
- placeholder="XXXX-YYYY-ZZZZ"
350
- value={inputCode}
351
- onChange={(e) => handleCodeInput(e.target.value)}
352
- maxLength={14}
353
- style={{ letterSpacing: "0.15em", fontSize: "1.5rem" }}
354
- />
355
- </div>
588
+ {phase === "success" && result && (
589
+ <SuccessPanel
590
+ result={result}
591
+ onTrust={() => void handleTrustAndView()}
592
+ onViewWithoutTrust={handleViewWithoutTrust}
593
+ />
594
+ )}
356
595
 
357
- {error && (
358
- <div
359
- className="alert alert-danger"
360
- style={{ maxWidth: "400px", margin: "0 auto 1rem" }}
361
- >
362
- {error}
363
- </div>
364
- )}
365
-
366
- <button
367
- className="btn btn-primary btn-lg"
368
- onClick={handleRespond}
369
- disabled={status === "waiting" || inputCode.replace(/[^0-9A-Z]/gi, "").length !== 12}
370
- >
371
- {status === "waiting" ? (
372
- <>
373
- <span className="spinner-border spinner-border-sm me-2" role="status" />
374
- Connecting...
375
- </>
376
- ) : (
377
- <>
378
- <i className="bi-link-45deg me-2" />
379
- Connect
380
- </>
381
- )}
382
- </button>
383
- </div>
596
+ {phase === "error" && localError && (
597
+ <ErrorPanel error={localError} onNewInvite={startOffer} onEnterCode={handleEnterManual} />
384
598
  )}
385
599
 
386
- {/* Success State */}
387
- {status === "success" && result && (
388
- <div className="text-center">
389
- <div className="mb-4">
390
- <i className="bi-check-circle-fill text-success display-3" />
391
- </div>
600
+ {phase === "expired" && (
601
+ <ExpiredPanel onNewInvite={startOffer} onEnterCode={handleEnterManual} />
602
+ )}
603
+ </div>
604
+ );
605
+ }
392
606
 
393
- <h5 className="mb-4">Connection Successful!</h5>
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>
394
644
 
395
- <div className="card mb-4" style={{ maxWidth: "400px", margin: "0 auto" }}>
396
- <div className="card-body">
397
- <p className="text-muted mb-2">Verify with the other person that you both see:</p>
398
- <h3 className="font-monospace text-primary mb-3" style={{ letterSpacing: "0.2em" }}>
399
- {result.confirmationHash}
400
- </h3>
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>
401
666
 
402
- <hr />
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>
403
680
 
404
- <div className="text-start">
405
- <small className="text-muted">Name:</small>
406
- <p className="mb-2">{result.remoteUser.name}</p>
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
+ }
407
692
 
408
- <small className="text-muted">User ID:</small>
409
- <p className="font-monospace small mb-0">{result.remoteUser.userId}</p>
410
- </div>
411
- </div>
412
- </div>
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
+ }
413
738
 
414
- <div className="d-flex gap-2 justify-content-center">
415
- <button className="btn btn-primary" onClick={handleSaveContact}>
416
- <i className="bi-check-lg me-2" />
417
- Trust & View Contact
418
- </button>
419
- <button className="btn btn-outline-secondary" onClick={handleReset}>
420
- Connect Another
421
- </button>
422
- </div>
423
- </div>
424
- )}
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
+ }
425
775
 
426
- {/* Error State */}
427
- {status === "error" && (
428
- <div className="text-center">
429
- <div className="mb-4">
430
- <i className="bi-x-circle-fill text-danger display-3" />
431
- </div>
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
+ }
432
784
 
433
- <h5 className="mb-4">Connection Failed</h5>
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
+ }
434
829
 
435
- <div className="alert alert-danger" style={{ maxWidth: "400px", margin: "0 auto 1rem" }}>
436
- {error}
437
- </div>
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
+ }
438
852
 
439
- <button className="btn btn-primary" onClick={handleReset}>
440
- Try Again
441
- </button>
442
- </div>
443
- )}
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>
444
869
  </div>
445
870
  );
446
871
  }