@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,51 +1,245 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.UserConnect = UserConnect;
4
7
  const jsx_runtime_1 = require("react/jsx-runtime");
8
+ /**
9
+ * Connect-to-user screen: QR invite, link share, manual code entry, and trust confirmation.
10
+ */
5
11
  const peers_sdk_1 = require("@peers-app/peers-sdk");
6
12
  const react_1 = require("react");
13
+ const react_qr_code_1 = __importDefault(require("react-qr-code"));
7
14
  const globals_1 = require("../../globals");
8
15
  const hooks_1 = require("../../hooks");
9
16
  const ui_loader_1 = require("../../ui-router/ui-loader");
17
+ const user_connect_invite_1 = require("./user-connect-invite");
18
+ const ERROR_COPY = {
19
+ "invalid-code": {
20
+ title: "That invite isn’t valid",
21
+ body: "Check the link or code and try again, or create a new invite.",
22
+ },
23
+ "no-route": {
24
+ title: "This device isn’t connected yet",
25
+ body: "Connect this device to the internet or another Peers device, then try again.",
26
+ },
27
+ "not-found-or-expired": {
28
+ title: "Invite not found or expired",
29
+ body: "Ask the other person to create a new invite, or enter a fresh code.",
30
+ },
31
+ "remote-not-waiting": {
32
+ title: "The other device stopped waiting",
33
+ body: "Ask them to open Connect again and create a new invite.",
34
+ },
35
+ canceled: {
36
+ title: "Connection canceled",
37
+ body: "You can create a new invite or enter another code whenever you’re ready.",
38
+ },
39
+ "protocol-error": {
40
+ title: "Something went wrong",
41
+ body: "Try again in a moment. If it keeps failing, create a new invite.",
42
+ },
43
+ };
44
+ function errorMessage(error) {
45
+ const base = ERROR_COPY[error.code];
46
+ if (error.code === "protocol-error" && error.detail) {
47
+ return { title: base.title, body: base.body };
48
+ }
49
+ return base;
50
+ }
51
+ function formatCountdown(remainingMs) {
52
+ const clamped = Math.max(0, remainingMs);
53
+ const totalSeconds = Math.ceil(clamped / 1000);
54
+ const minutes = Math.floor(totalSeconds / 60);
55
+ const seconds = totalSeconds % 60;
56
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
57
+ }
58
+ function canShare() {
59
+ return typeof navigator !== "undefined" && typeof navigator.share === "function";
60
+ }
61
+ /**
62
+ * Polished Connect to user screen. Creating an invite is the default path;
63
+ * inbound links open a review state before connecting.
64
+ */
10
65
  function UserConnect() {
11
- const [mode, setMode] = (0, react_1.useState)("select");
12
- const [status, setStatus] = (0, react_1.useState)("idle");
66
+ const [phase, setPhase] = (0, react_1.useState)("offer");
13
67
  const [connectionCode, setConnectionCode] = (0, react_1.useState)("");
68
+ const [inviteUrl, setInviteUrl] = (0, react_1.useState)("");
69
+ const [expiresAt, setExpiresAt] = (0, react_1.useState)(null);
70
+ const [remainingMs, setRemainingMs] = (0, react_1.useState)(peers_sdk_1.USER_CONNECT_TTL_MS);
14
71
  const [inputCode, setInputCode] = (0, react_1.useState)("");
72
+ const [reviewCode, setReviewCode] = (0, react_1.useState)("");
15
73
  const [result, setResult] = (0, react_1.useState)(null);
16
- const [error, setError] = (0, react_1.useState)("");
17
- const [copied, setCopied] = (0, react_1.useState)(false);
18
- // Subscribe to userConnectStatus from the device layer
74
+ const [localError, setLocalError] = (0, react_1.useState)(null);
75
+ const [feedback, setFeedback] = (0, react_1.useState)("");
76
+ const [submitting, setSubmitting] = (0, react_1.useState)(false);
77
+ const [hasRoute, setHasRoute] = (0, react_1.useState)(null);
19
78
  const [connectStatus] = (0, hooks_1.useObservable)(peers_sdk_1.userConnectStatus);
20
- const handleConnectStatusChange = (0, react_1.useCallback)(async (connectStatusValue) => {
21
- if (!connectStatusValue || status === "success")
79
+ const [answerCode] = (0, hooks_1.useObservable)(peers_sdk_1.userConnectCodeAnswer);
80
+ const completedRef = (0, react_1.useRef)(false);
81
+ const offerStartedRef = (0, react_1.useRef)(false);
82
+ const answerWasActiveRef = (0, react_1.useRef)(false);
83
+ const protocolActiveRef = (0, react_1.useRef)(false);
84
+ const ceremonyGenerationRef = (0, react_1.useRef)(0);
85
+ const clearCeremony = (0, react_1.useCallback)((options) => {
86
+ ceremonyGenerationRef.current += 1;
87
+ completedRef.current = false;
88
+ answerWasActiveRef.current = false;
89
+ protocolActiveRef.current = false;
90
+ (0, peers_sdk_1.userConnectCodeOffer)("");
91
+ (0, peers_sdk_1.userConnectCodeAnswer)("");
92
+ (0, peers_sdk_1.userConnectStatus)(peers_sdk_1.USER_CONNECT_IDLE_STATUS);
93
+ if (!options?.preservePendingIntent) {
94
+ (0, user_connect_invite_1.clearUserConnectPendingIntent)();
95
+ }
96
+ }, []);
97
+ const startOffer = (0, react_1.useCallback)(() => {
98
+ completedRef.current = false;
99
+ clearCeremony();
100
+ const code = (0, peers_sdk_1.generateConnectionCode)();
101
+ const formatted = (0, peers_sdk_1.formatConnectionCode)(code.code);
102
+ setConnectionCode(formatted);
103
+ setInviteUrl((0, user_connect_invite_1.buildUserConnectInviteUrl)(code.code));
104
+ setExpiresAt(Date.now() + peers_sdk_1.USER_CONNECT_TTL_MS);
105
+ setRemainingMs(peers_sdk_1.USER_CONNECT_TTL_MS);
106
+ setLocalError(null);
107
+ setResult(null);
108
+ setFeedback("");
109
+ setSubmitting(false);
110
+ setPhase("offer");
111
+ (0, peers_sdk_1.userConnectCodeOffer)(code.code);
112
+ }, [clearCeremony]);
113
+ const showError = (0, react_1.useCallback)((error) => {
114
+ answerWasActiveRef.current = false;
115
+ protocolActiveRef.current = false;
116
+ setLocalError(error);
117
+ setSubmitting(false);
118
+ setPhase("error");
119
+ }, []);
120
+ const applyPendingInvite = (0, react_1.useCallback)(() => {
121
+ const pending = (0, user_connect_invite_1.readUserConnectPendingIntent)();
122
+ if (!pending.ok && pending.reason === "none") {
123
+ return false;
124
+ }
125
+ clearCeremony({ preservePendingIntent: true });
126
+ offerStartedRef.current = true;
127
+ setConnectionCode("");
128
+ setInviteUrl("");
129
+ setExpiresAt(null);
130
+ setLocalError(null);
131
+ setResult(null);
132
+ setFeedback("");
133
+ setSubmitting(false);
134
+ if (!pending.ok && pending.reason === "invalid") {
135
+ setReviewCode("");
136
+ setInputCode("");
137
+ setLocalError({ code: "invalid-code" });
138
+ setPhase("error");
139
+ return true;
140
+ }
141
+ if (!pending.ok && pending.reason === "expired") {
142
+ setReviewCode("");
143
+ setInputCode("");
144
+ setPhase("expired");
145
+ return true;
146
+ }
147
+ if (pending.ok) {
148
+ const formatted = (0, peers_sdk_1.formatConnectionCode)(pending.intent.code);
149
+ setReviewCode(formatted);
150
+ setInputCode(formatted);
151
+ setPhase("review");
152
+ }
153
+ return true;
154
+ }, [clearCeremony]);
155
+ const refreshRouteStatus = (0, react_1.useCallback)(async () => {
156
+ try {
157
+ const devices = await peers_sdk_1.rpcServerCalls.listConnectedDevices();
158
+ setHasRoute(devices.length > 0);
159
+ }
160
+ catch {
161
+ // If the RPC is unavailable, do not block entry — route may appear shortly.
162
+ setHasRoute(null);
163
+ }
164
+ }, []);
165
+ // Capture inbound intent once on mount; otherwise auto-start an offer.
166
+ (0, react_1.useEffect)(() => {
167
+ if (applyPendingInvite()) {
168
+ return;
169
+ }
170
+ if (!offerStartedRef.current) {
171
+ offerStartedRef.current = true;
172
+ startOffer();
173
+ }
174
+ }, [applyPendingInvite, startOffer]);
175
+ // A second invite can arrive while this screen is already mounted.
176
+ (0, react_1.useEffect)(() => {
177
+ const captureInboundInvite = () => {
178
+ const path = window.location.hash.slice(1).split("?")[0];
179
+ if (path !== "contacts/connect") {
180
+ return;
181
+ }
182
+ const captured = (0, user_connect_invite_1.captureUserConnectInviteFromLocation)();
183
+ if (captured.ok || captured.reason !== "none") {
184
+ applyPendingInvite();
185
+ }
186
+ };
187
+ window.addEventListener("hashchange", captureInboundInvite);
188
+ return () => window.removeEventListener("hashchange", captureInboundInvite);
189
+ }, [applyPendingInvite]);
190
+ (0, react_1.useEffect)(() => {
191
+ void refreshRouteStatus();
192
+ }, [refreshRouteStatus]);
193
+ // Countdown while an offer is active.
194
+ (0, react_1.useEffect)(() => {
195
+ if (phase !== "offer" || expiresAt === null) {
22
196
  return;
23
- if (connectStatusValue.startsWith("Error:")) {
24
- // Error status
25
- setError(connectStatusValue.replace("Error: ", ""));
26
- setStatus("error");
27
- }
28
- else if (connectStatusValue.length > 0) {
29
- // Success - connectStatus is the remote userId
30
- const remoteUserId = connectStatusValue;
197
+ }
198
+ const tick = () => {
199
+ const remaining = expiresAt - Date.now();
200
+ setRemainingMs(remaining);
201
+ if (remaining <= 0) {
202
+ clearCeremony();
203
+ setPhase("expired");
204
+ }
205
+ };
206
+ tick();
207
+ const id = window.setInterval(tick, 1000);
208
+ return () => window.clearInterval(id);
209
+ }, [phase, expiresAt, clearCeremony]);
210
+ const handleProtocolStatus = (0, react_1.useCallback)(async (status) => {
211
+ if (!status || completedRef.current) {
212
+ return;
213
+ }
214
+ if (status.kind === "connecting") {
215
+ protocolActiveRef.current = true;
216
+ return;
217
+ }
218
+ if (status.kind === "connected") {
219
+ const generation = ceremonyGenerationRef.current;
220
+ completedRef.current = true;
31
221
  try {
32
222
  const userContext = await (0, peers_sdk_1.getUserContext)();
33
223
  const me = await (0, peers_sdk_1.getMe)();
34
- const remoteUser = await (0, peers_sdk_1.Users)(userContext.userDataContext).get(remoteUserId);
224
+ const remoteUser = await (0, peers_sdk_1.Users)(userContext.userDataContext).get(status.remoteUserId);
225
+ if (generation !== ceremonyGenerationRef.current) {
226
+ return;
227
+ }
35
228
  if (!remoteUser) {
36
- setError("Could not find connected user");
37
- setStatus("error");
229
+ showError({ code: "protocol-error", detail: "Could not find connected user" });
230
+ return;
231
+ }
232
+ const remoteDevice = await (0, peers_sdk_1.Devices)(userContext.userDataContext).get(status.remoteDeviceId);
233
+ if (generation !== ceremonyGenerationRef.current) {
38
234
  return;
39
235
  }
40
- const remoteDevice = await (0, peers_sdk_1.Devices)(userContext.userDataContext).findOne({
41
- userId: remoteUserId,
42
- });
43
236
  if (!remoteDevice) {
44
- setError("Could not find connected device but connection was successful");
45
- setStatus("error");
237
+ showError({
238
+ code: "protocol-error",
239
+ detail: "Could not find connected device",
240
+ });
46
241
  return;
47
242
  }
48
- // Build user connect info for confirmation hash
49
243
  const myInfo = {
50
244
  userId: me.userId,
51
245
  publicKey: me.publicKey,
@@ -58,136 +252,192 @@ function UserConnect() {
58
252
  publicBoxKey: remoteUser.publicBoxKey,
59
253
  deviceId: remoteDevice.deviceId,
60
254
  };
61
- const confirmationHash = (0, peers_sdk_1.generateConfirmationHash)(myInfo, remoteInfo);
62
- setResult({ remoteUser, confirmationHash });
63
- setStatus("success");
64
- // Clear the codes
255
+ setResult({
256
+ remoteUser,
257
+ confirmationHash: (0, peers_sdk_1.generateConfirmationHash)(myInfo, remoteInfo),
258
+ });
259
+ (0, user_connect_invite_1.clearUserConnectPendingIntent)();
65
260
  (0, peers_sdk_1.userConnectCodeOffer)("");
66
261
  (0, peers_sdk_1.userConnectCodeAnswer)("");
262
+ setSubmitting(false);
263
+ setPhase("success");
67
264
  }
68
265
  catch (err) {
69
- setError(err instanceof Error ? err.message : String(err));
70
- setStatus("error");
266
+ if (generation !== ceremonyGenerationRef.current) {
267
+ return;
268
+ }
269
+ showError({
270
+ code: "protocol-error",
271
+ detail: err instanceof Error ? err.message : String(err),
272
+ });
71
273
  }
274
+ return;
72
275
  }
73
- }, [status]);
74
- // Also set up a direct subscription after loading is complete
276
+ if (status.kind === "error") {
277
+ showError({ code: status.code, detail: status.detail });
278
+ }
279
+ }, [showError]);
75
280
  (0, react_1.useEffect)(() => {
76
- let disposed = false;
77
- let subscription;
78
- peers_sdk_1.userConnectStatus.loadingPromise.then(() => {
79
- if (disposed)
80
- return;
81
- subscription = peers_sdk_1.userConnectStatus.subscribe(() => {
82
- // Force a re-render by checking the current value
83
- const currentStatus = (0, peers_sdk_1.userConnectStatus)();
84
- if (currentStatus && typeof currentStatus === "string") {
85
- handleConnectStatusChange(currentStatus);
86
- }
87
- });
88
- });
89
- return () => {
90
- disposed = true;
91
- subscription?.dispose();
92
- };
93
- }, [handleConnectStatusChange]);
94
- // React to userConnectStatus changes from useObservable
281
+ void handleProtocolStatus(connectStatus);
282
+ }, [connectStatus, handleProtocolStatus]);
95
283
  (0, react_1.useEffect)(() => {
96
- if (connectStatus && typeof connectStatus === "string") {
97
- handleConnectStatusChange(connectStatus);
284
+ if (phase !== "connecting" ||
285
+ !connectStatus ||
286
+ connectStatus.kind !== "idle" ||
287
+ (answerCode && !protocolActiveRef.current) ||
288
+ (!answerCode && !answerWasActiveRef.current)) {
289
+ return;
98
290
  }
99
- }, [connectStatus, handleConnectStatusChange]);
100
- // Clean up on unmount
291
+ // Terminal handlers publish error/connected immediately after clearing the
292
+ // answer. Defer idle recovery one turn so those statuses win.
293
+ const timeout = window.setTimeout(() => {
294
+ const latestStatus = (0, peers_sdk_1.userConnectStatus)();
295
+ const latestAnswer = (0, peers_sdk_1.userConnectCodeAnswer)();
296
+ if (latestStatus.kind !== "idle") {
297
+ return;
298
+ }
299
+ if (latestAnswer) {
300
+ if (protocolActiveRef.current) {
301
+ clearCeremony();
302
+ showError({ code: "canceled" });
303
+ }
304
+ return;
305
+ }
306
+ if (answerWasActiveRef.current) {
307
+ clearCeremony();
308
+ setSubmitting(false);
309
+ setPhase("expired");
310
+ }
311
+ }, 0);
312
+ return () => window.clearTimeout(timeout);
313
+ }, [answerCode, clearCeremony, connectStatus, phase, showError]);
101
314
  (0, react_1.useEffect)(() => {
102
315
  return () => {
103
- if (mode === "initiate" && status === "waiting") {
104
- (0, peers_sdk_1.userConnectCodeOffer)("");
105
- (0, peers_sdk_1.userConnectCodeAnswer)("");
316
+ // Leaving the screen cancels an in-flight offer/answer.
317
+ (0, peers_sdk_1.userConnectCodeOffer)("");
318
+ (0, peers_sdk_1.userConnectCodeAnswer)("");
319
+ (0, peers_sdk_1.userConnectStatus)(peers_sdk_1.USER_CONNECT_IDLE_STATUS);
320
+ const path = window.location.hash.slice(1).split("?")[0];
321
+ if (path !== "contacts/connect") {
322
+ (0, user_connect_invite_1.clearUserConnectPendingIntent)();
106
323
  }
107
324
  };
108
- }, [mode, status]);
109
- const handleInitiate = (0, react_1.useCallback)(async () => {
110
- setMode("initiate");
111
- setStatus("waiting");
112
- setError("");
113
- (0, peers_sdk_1.userConnectStatus)(""); // Clear any previous status
114
- // Generate the connection code
115
- const code = (0, peers_sdk_1.generateConnectionCode)();
116
- (0, peers_sdk_1.userConnectCodeOffer)(code.code);
117
- const formattedCode = (0, peers_sdk_1.formatConnectionCode)(code.code);
118
- setConnectionCode(formattedCode);
119
325
  }, []);
120
- const handleRespond = (0, react_1.useCallback)(async () => {
121
- if (inputCode.replace(/[^0-9A-Za-z]/g, "").length !== 12) {
122
- setError("Please enter a valid 12-character connection code");
326
+ const handleConfirmReview = (0, react_1.useCallback)(async () => {
327
+ if (submitting)
328
+ return;
329
+ const pending = (0, user_connect_invite_1.readUserConnectPendingIntent)();
330
+ if (!pending.ok) {
331
+ if (pending.reason === "invalid" || pending.reason === "expired") {
332
+ applyPendingInvite();
333
+ }
334
+ else {
335
+ showError({ code: "canceled" });
336
+ }
123
337
  return;
124
338
  }
125
- setStatus("waiting");
126
- setError("");
127
- (0, peers_sdk_1.userConnectStatus)(""); // Clear any previous status
128
- (0, peers_sdk_1.userConnectCodeAnswer)(inputCode);
129
- }, [inputCode]);
130
- const handleCancel = (0, react_1.useCallback)(async () => {
131
- (0, peers_sdk_1.userConnectCodeOffer)("");
132
- (0, peers_sdk_1.userConnectCodeAnswer)("");
133
- (0, peers_sdk_1.userConnectStatus)("");
134
- setMode("select");
135
- setStatus("idle");
136
- setConnectionCode("");
137
- setError("");
138
- }, []);
139
- const handleReset = (0, react_1.useCallback)(() => {
140
- (0, peers_sdk_1.userConnectCodeOffer)("");
141
- (0, peers_sdk_1.userConnectCodeAnswer)("");
142
- (0, peers_sdk_1.userConnectStatus)("");
143
- setMode("select");
144
- setStatus("idle");
145
- setConnectionCode("");
146
- setInputCode("");
339
+ const normalized = (0, user_connect_invite_1.normalizeUserConnectCode)(reviewCode);
340
+ if (!normalized || normalized !== pending.intent.code) {
341
+ showError({ code: "invalid-code" });
342
+ return;
343
+ }
344
+ ceremonyGenerationRef.current += 1;
345
+ completedRef.current = false;
346
+ answerWasActiveRef.current = true;
347
+ protocolActiveRef.current = false;
348
+ setSubmitting(true);
349
+ setPhase("connecting");
350
+ setLocalError(null);
351
+ (0, user_connect_invite_1.clearUserConnectPendingIntent)();
352
+ (0, peers_sdk_1.userConnectStatus)(peers_sdk_1.USER_CONNECT_IDLE_STATUS);
353
+ (0, peers_sdk_1.userConnectCodeAnswer)(normalized);
354
+ }, [applyPendingInvite, reviewCode, showError, submitting]);
355
+ const handleManualConnect = (0, react_1.useCallback)(async () => {
356
+ if (submitting)
357
+ return;
358
+ const normalized = (0, user_connect_invite_1.normalizeUserConnectCode)(inputCode);
359
+ if (!normalized) {
360
+ setLocalError({ code: "invalid-code" });
361
+ return;
362
+ }
363
+ ceremonyGenerationRef.current += 1;
364
+ completedRef.current = false;
365
+ answerWasActiveRef.current = true;
366
+ protocolActiveRef.current = false;
367
+ setSubmitting(true);
368
+ setPhase("connecting");
369
+ setLocalError(null);
370
+ (0, user_connect_invite_1.clearUserConnectPendingIntent)();
371
+ (0, peers_sdk_1.userConnectStatus)(peers_sdk_1.USER_CONNECT_IDLE_STATUS);
372
+ (0, peers_sdk_1.userConnectCodeAnswer)(normalized);
373
+ }, [inputCode, submitting]);
374
+ const handleCancel = (0, react_1.useCallback)(() => {
375
+ clearCeremony();
376
+ setSubmitting(false);
377
+ setLocalError(null);
147
378
  setResult(null);
148
- setError("");
149
- setCopied(false);
150
- }, []);
151
- const handleSaveContact = (0, react_1.useCallback)(async () => {
379
+ (0, globals_1.mainContentPath)("contacts");
380
+ }, [clearCeremony]);
381
+ const handleEnterManual = (0, react_1.useCallback)(() => {
382
+ clearCeremony();
383
+ setSubmitting(false);
384
+ setLocalError(null);
385
+ setInputCode("");
386
+ setPhase("manual");
387
+ }, [clearCeremony]);
388
+ const handleCopyLink = (0, react_1.useCallback)(async () => {
389
+ if (!inviteUrl)
390
+ return;
391
+ const ok = await (0, peers_sdk_1.copyToClipboard)(inviteUrl);
392
+ setFeedback(ok ? "Link copied" : "Could not copy link");
393
+ window.setTimeout(() => setFeedback(""), 2000);
394
+ }, [inviteUrl]);
395
+ const handleShareLink = (0, react_1.useCallback)(async () => {
396
+ if (!inviteUrl || !canShare()) {
397
+ await handleCopyLink();
398
+ return;
399
+ }
400
+ try {
401
+ await navigator.share({
402
+ title: "Connect on Peers",
403
+ text: (0, user_connect_invite_1.buildUserConnectShareText)(inviteUrl),
404
+ url: inviteUrl,
405
+ });
406
+ setFeedback("Share sheet opened");
407
+ window.setTimeout(() => setFeedback(""), 2000);
408
+ }
409
+ catch (err) {
410
+ // User cancel is neutral.
411
+ if (err instanceof DOMException && err.name === "AbortError") {
412
+ return;
413
+ }
414
+ await handleCopyLink();
415
+ }
416
+ }, [handleCopyLink, inviteUrl]);
417
+ const handleTrustAndView = (0, react_1.useCallback)(async () => {
152
418
  if (!result)
153
419
  return;
154
420
  try {
155
421
  const userContext = await (0, peers_sdk_1.getUserContext)();
156
- // Contact is already saved by the device layer, just set trust level
157
422
  await (0, peers_sdk_1.setUserTrustLevel)(result.remoteUser.userId, peers_sdk_1.TrustLevel.Trusted, userContext.userDataContext);
158
- // Navigate to contact details
423
+ clearCeremony();
159
424
  (0, globals_1.mainContentPath)(`contacts/${result.remoteUser.userId}`);
160
425
  }
161
426
  catch (err) {
162
- setError(err instanceof Error ? err.message : String(err));
427
+ showError({
428
+ code: "protocol-error",
429
+ detail: err instanceof Error ? err.message : String(err),
430
+ });
163
431
  }
164
- }, [result]);
165
- // Copy connection code to clipboard
166
- const handleCopyCode = (0, react_1.useCallback)(async () => {
167
- if (!connectionCode)
432
+ }, [clearCeremony, result, showError]);
433
+ const handleViewWithoutTrust = (0, react_1.useCallback)(() => {
434
+ if (!result)
168
435
  return;
169
- try {
170
- await navigator.clipboard.writeText(connectionCode);
171
- setCopied(true);
172
- setTimeout(() => setCopied(false), 2000);
173
- }
174
- catch (_err) {
175
- // Fallback for older browsers
176
- const textArea = document.createElement("textarea");
177
- textArea.value = connectionCode;
178
- document.body.appendChild(textArea);
179
- textArea.select();
180
- document.execCommand("copy");
181
- document.body.removeChild(textArea);
182
- setCopied(true);
183
- setTimeout(() => setCopied(false), 2000);
184
- }
185
- }, [connectionCode]);
186
- // Format input code as user types
436
+ clearCeremony();
437
+ (0, globals_1.mainContentPath)(`contacts/${result.remoteUser.userId}`);
438
+ }, [clearCeremony, result]);
187
439
  const handleCodeInput = (value) => {
188
- // Remove non-alphanumeric characters
189
440
  const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "");
190
- // Format as XXXX-YYYY-ZZZZ
191
441
  let formatted = "";
192
442
  for (let i = 0; i < cleaned.length && i < 12; i++) {
193
443
  if (i === 4 || i === 8)
@@ -195,8 +445,34 @@ function UserConnect() {
195
445
  formatted += cleaned[i];
196
446
  }
197
447
  setInputCode(formatted);
448
+ setLocalError(null);
198
449
  };
199
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center mb-4", children: [(0, jsx_runtime_1.jsxs)("h4", { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-person-plus-fill me-2" }), "Connect to New User"] }), mode !== "select" && ((0, jsx_runtime_1.jsxs)("button", { className: "btn btn-outline-secondary btn-sm", onClick: handleReset, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-arrow-left me-1" }), "Back"] }))] }), mode === "select" && ((0, jsx_runtime_1.jsxs)("div", { className: "row g-3", children: [(0, jsx_runtime_1.jsx)("div", { className: "col-md-6", children: (0, jsx_runtime_1.jsx)("div", { className: "card h-100 border-primary", style: { cursor: "pointer" }, onClick: handleInitiate, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body text-center p-4", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-qr-code display-4 text-primary mb-3" }), (0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Create Connection Code" }), (0, jsx_runtime_1.jsx)("p", { className: "card-text text-muted", children: "Generate a code to share with someone who wants to connect with you" })] }) }) }), (0, jsx_runtime_1.jsx)("div", { className: "col-md-6", children: (0, jsx_runtime_1.jsx)("div", { className: "card h-100 border-success", style: { cursor: "pointer" }, onClick: () => setMode("respond"), children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body text-center p-4", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-keyboard display-4 text-success mb-3" }), (0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Enter Connection Code" }), (0, jsx_runtime_1.jsx)("p", { className: "card-text text-muted", children: "Enter a code that someone shared with you to connect" })] }) }) })] })), mode === "initiate" && status === "waiting" && ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [(0, jsx_runtime_1.jsx)("div", { className: "mb-4", children: (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "Share this code with the person you want to connect with:" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-4", style: { maxWidth: "400px", margin: "0 auto" }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "input-group", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", className: "form-control form-control-lg text-center font-monospace", value: connectionCode || "XXXX-YYYY-ZZZZ", readOnly: true, style: { letterSpacing: "0.15em", fontSize: "1.5rem" } }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-outline-primary", onClick: handleCopyCode, title: "Copy to clipboard", children: (0, jsx_runtime_1.jsx)("i", { className: copied ? "bi-check-lg" : "bi-clipboard" }) })] }), copied && (0, jsx_runtime_1.jsx)("small", { className: "text-success mt-1 d-block", children: "Copied!" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-4", children: [(0, jsx_runtime_1.jsx)("div", { className: "spinner-border spinner-border-sm text-primary me-2", role: "status" }), (0, jsx_runtime_1.jsx)("span", { className: "text-muted", children: "Waiting for connection..." })] }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted small", children: "This code will expire in 10 minutes" }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-outline-secondary", onClick: handleCancel, children: "Cancel" })] })), mode === "respond" && status !== "success" && ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [(0, jsx_runtime_1.jsx)("div", { className: "mb-4", children: (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "Enter the connection code shared with you:" }) }), (0, jsx_runtime_1.jsx)("div", { className: "mb-4", style: { maxWidth: "400px", margin: "0 auto" }, children: (0, jsx_runtime_1.jsx)("input", { type: "text", className: "form-control form-control-lg text-center font-monospace", placeholder: "XXXX-YYYY-ZZZZ", value: inputCode, onChange: (e) => handleCodeInput(e.target.value), maxLength: 14, style: { letterSpacing: "0.15em", fontSize: "1.5rem" } }) }), error && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", style: { maxWidth: "400px", margin: "0 auto 1rem" }, children: error })), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-primary btn-lg", onClick: handleRespond, disabled: status === "waiting" || inputCode.replace(/[^0-9A-Z]/gi, "").length !== 12, children: status === "waiting" ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-2", role: "status" }), "Connecting..."] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-link-45deg me-2" }), "Connect"] })) })] })), status === "success" && result && ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [(0, jsx_runtime_1.jsx)("div", { className: "mb-4", children: (0, jsx_runtime_1.jsx)("i", { className: "bi-check-circle-fill text-success display-3" }) }), (0, jsx_runtime_1.jsx)("h5", { className: "mb-4", children: "Connection Successful!" }), (0, jsx_runtime_1.jsx)("div", { className: "card mb-4", style: { maxWidth: "400px", margin: "0 auto" }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-2", children: "Verify with the other person that you both see:" }), (0, jsx_runtime_1.jsx)("h3", { className: "font-monospace text-primary mb-3", style: { letterSpacing: "0.2em" }, children: result.confirmationHash }), (0, jsx_runtime_1.jsx)("hr", {}), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: "Name:" }), (0, jsx_runtime_1.jsx)("p", { className: "mb-2", children: result.remoteUser.name }), (0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: "User ID:" }), (0, jsx_runtime_1.jsx)("p", { className: "font-monospace small mb-0", children: result.remoteUser.userId })] })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [(0, jsx_runtime_1.jsxs)("button", { className: "btn btn-primary", onClick: handleSaveContact, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-check-lg me-2" }), "Trust & View Contact"] }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-outline-secondary", onClick: handleReset, children: "Connect Another" })] })] })), status === "error" && ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", children: [(0, jsx_runtime_1.jsx)("div", { className: "mb-4", children: (0, jsx_runtime_1.jsx)("i", { className: "bi-x-circle-fill text-danger display-3" }) }), (0, jsx_runtime_1.jsx)("h5", { className: "mb-4", children: "Connection Failed" }), (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", style: { maxWidth: "400px", margin: "0 auto 1rem" }, children: error }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-primary", onClick: handleReset, children: "Try Again" })] }))] }));
450
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center mb-3", children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-0", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-person-plus-fill me-2", "aria-hidden": "true" }), "Connect to user"] }), phase !== "offer" && phase !== "success" && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-outline-secondary btn-sm", onClick: handleCancel, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-arrow-left me-1", "aria-hidden": "true" }), "Back"] }))] }), hasRoute === false && phase !== "success" && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning", role: "status", children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-start gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsx)("div", { children: "This device isn\u2019t connected to the internet or another Peers device yet. You can still prepare an invite; connection will work once a route appears." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-warning", onClick: () => void refreshRouteStatus(), children: "Check again" })] }) })), (0, jsx_runtime_1.jsx)("div", { "aria-live": "polite", className: "visually-hidden", children: feedback }), phase === "offer" && ((0, jsx_runtime_1.jsx)(OfferPanel, { connectionCode: connectionCode, inviteUrl: inviteUrl, remainingMs: remainingMs, feedback: feedback, onShare: () => void handleShareLink(), onCopy: () => void handleCopyLink(), onEnterManual: handleEnterManual, onCancel: handleCancel })), phase === "manual" && ((0, jsx_runtime_1.jsx)(ManualEntryPanel, { inputCode: inputCode, localError: localError, submitting: submitting, onChange: handleCodeInput, onConnect: () => void handleManualConnect() })), phase === "review" && ((0, jsx_runtime_1.jsx)(ReviewPanel, { reviewCode: reviewCode, submitting: submitting, onConfirm: () => void handleConfirmReview(), onEnterManual: handleEnterManual })), phase === "connecting" && (0, jsx_runtime_1.jsx)(ConnectingPanel, {}), phase === "success" && result && ((0, jsx_runtime_1.jsx)(SuccessPanel, { result: result, onTrust: () => void handleTrustAndView(), onViewWithoutTrust: handleViewWithoutTrust })), phase === "error" && localError && ((0, jsx_runtime_1.jsx)(ErrorPanel, { error: localError, onNewInvite: startOffer, onEnterCode: handleEnterManual })), phase === "expired" && ((0, jsx_runtime_1.jsx)(ExpiredPanel, { onNewInvite: startOffer, onEnterCode: handleEnterManual }))] }));
451
+ }
452
+ function OfferPanel(props) {
453
+ const shareAvailable = canShare();
454
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-3", children: "Ask the other person to scan this with their camera" }), (0, jsx_runtime_1.jsx)("div", { className: "d-inline-block p-3 mb-3 rounded", style: { background: "#ffffff" }, "aria-label": "Connection QR code", children: props.inviteUrl ? ((0, jsx_runtime_1.jsx)(react_qr_code_1.default, { value: props.inviteUrl, size: 220, bgColor: "#FFFFFF", fgColor: "#000000", style: { height: "auto", maxWidth: "100%", width: "min(70vw, 220px)" }, viewBox: "0 0 256 256" })) : ((0, jsx_runtime_1.jsx)("div", { className: "spinner-border text-primary", role: "status", "aria-label": "Preparing invite" })) }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-2", children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm text-primary me-2", role: "status" }), (0, jsx_runtime_1.jsx)("span", { className: "text-muted", children: "Waiting for connection\u2026" })] }), (0, jsx_runtime_1.jsxs)("p", { className: "text-muted small mb-3", "aria-live": "polite", children: ["Expires in ", formatCountdown(props.remainingMs)] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-grid gap-2 mb-3", children: [shareAvailable ? ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-primary", onClick: props.onShare, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-share me-2", "aria-hidden": "true" }), "Share link"] })) : null, (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-outline-primary", onClick: props.onCopy, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-clipboard me-2", "aria-hidden": "true" }), "Copy link"] }), props.feedback ? (0, jsx_runtime_1.jsx)("small", { className: "text-success", children: props.feedback }) : null] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("label", { className: "form-label small text-muted mb-1", htmlFor: "user-connect-fallback-code", children: "Or share this code" }), (0, jsx_runtime_1.jsx)("input", { id: "user-connect-fallback-code", type: "text", className: "form-control form-control-lg text-center font-monospace", value: props.connectionCode, readOnly: true, "aria-label": "Connection code" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center flex-wrap", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-link", onClick: props.onEnterManual, children: "Enter a code instead" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onCancel, children: "Cancel" })] })] }));
455
+ }
456
+ function ManualEntryPanel(props) {
457
+ const ready = props.inputCode.replace(/[^0-9A-Z]/gi, "").length === 12;
458
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 400, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-3", children: "Enter the connection code shared with you" }), (0, jsx_runtime_1.jsx)("input", { type: "text", className: "form-control form-control-lg text-center font-monospace mb-3", placeholder: "XXXX-YYYY-ZZZZ", value: props.inputCode, onChange: (e) => props.onChange(e.target.value), maxLength: 14, "aria-label": "Connection code", autoComplete: "off" }), props.localError ? ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", children: errorMessage(props.localError).body })) : null, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary btn-lg", onClick: props.onConnect, disabled: props.submitting || !ready, children: props.submitting ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-2", role: "status" }), "Connecting\u2026"] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-link-45deg me-2", "aria-hidden": "true" }), "Connect"] })) })] }));
459
+ }
460
+ function ReviewPanel(props) {
461
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 400, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-2", children: "You opened a connection invite" }), (0, jsx_runtime_1.jsx)("p", { className: "font-monospace fs-4 mb-3", children: props.reviewCode }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted small mb-4", children: "Both of you need Peers open. Confirm to connect with the person who shared this invite." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary btn-lg mb-2", onClick: props.onConfirm, disabled: props.submitting, children: props.submitting ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-2", role: "status" }), "Connecting\u2026"] })) : ("Connect") }), (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-link", onClick: props.onEnterManual, children: "Enter a different code" }) })] }));
462
+ }
463
+ function ConnectingPanel() {
464
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center py-5", children: [(0, jsx_runtime_1.jsx)("div", { className: "spinner-border text-primary mb-3", role: "status", "aria-label": "Connecting" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-0", children: "Connecting\u2026" })] }));
465
+ }
466
+ function SuccessPanel(props) {
467
+ const [showDetails, setShowDetails] = (0, react_1.useState)(false);
468
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 420, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-check-circle-fill text-success display-3 mb-3", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("h5", { className: "mb-3", children: "Connected" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-2", children: "Verify with the other person that you both see:" }), (0, jsx_runtime_1.jsx)("h3", { className: "font-monospace text-primary mb-3", style: { letterSpacing: "0.15em" }, children: props.result.confirmationHash }), (0, jsx_runtime_1.jsxs)("p", { className: "mb-1", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-muted", children: "Name: " }), props.result.remoteUser.name || "Unnamed"] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-link btn-sm mb-3", onClick: () => setShowDetails((v) => !v), "aria-expanded": showDetails, children: showDetails ? "Hide details" : "Show details" }), showDetails ? ((0, jsx_runtime_1.jsx)("p", { className: "font-monospace small text-muted mb-3", children: props.result.remoteUser.userId })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "d-grid gap-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", onClick: props.onTrust, children: "Trust and view contact" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onViewWithoutTrust, children: "View without trusting" })] })] }));
469
+ }
470
+ function ErrorPanel(props) {
471
+ const copy = errorMessage(props.error);
472
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 420, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-x-circle-fill text-danger display-3 mb-3", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("h5", { className: "mb-2", children: copy.title }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-4", children: copy.body }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center flex-wrap", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", onClick: props.onNewInvite, children: "Create a new invite" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onEnterCode, children: "Enter another code" })] })] }));
473
+ }
474
+ function ExpiredPanel(props) {
475
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 420, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-hourglass-bottom text-warning display-3 mb-3", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("h5", { className: "mb-2", children: "Invite expired" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-4", children: "Create a new invite or enter a fresh code from the other person." }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center flex-wrap", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", onClick: props.onNewInvite, children: "Create a new invite" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onEnterCode, children: "Enter another code" })] })] }));
200
476
  }
201
477
  (0, ui_loader_1.registerInternalPeersUI)({
202
478
  peersUIId: "000user00connect0screen01",
@@ -0,0 +1 @@
1
+ export {};