@peers-app/peers-ui 0.20.3 → 0.20.5
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.
- package/dist/root-layout/root-layout-host.js +26 -0
- package/dist/root-layout/root-layout-host.test.d.ts +1 -0
- package/dist/root-layout/root-layout-host.test.js +84 -0
- package/dist/screens/contacts/user-connect-invite.d.ts +118 -0
- package/dist/screens/contacts/user-connect-invite.js +264 -0
- package/dist/screens/contacts/user-connect-invite.test.d.ts +1 -0
- package/dist/screens/contacts/user-connect-invite.test.js +215 -0
- package/dist/screens/contacts/user-connect.d.ts +7 -0
- package/dist/screens/contacts/user-connect.js +402 -126
- package/dist/screens/contacts/user-connect.test.d.ts +1 -0
- package/dist/screens/contacts/user-connect.test.js +291 -0
- package/package.json +5 -4
- package/src/root-layout/root-layout-host.test.tsx +106 -0
- package/src/root-layout/root-layout-host.tsx +33 -2
- package/src/screens/contacts/user-connect-invite.test.ts +256 -0
- package/src/screens/contacts/user-connect-invite.ts +332 -0
- package/src/screens/contacts/user-connect.test.tsx +382 -0
- package/src/screens/contacts/user-connect.tsx +735 -310
|
@@ -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
|
|
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 [
|
|
32
|
-
const [
|
|
33
|
-
const [
|
|
34
|
-
const [
|
|
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 [
|
|
37
|
-
const [
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
[
|
|
334
|
+
[showError],
|
|
103
335
|
);
|
|
104
336
|
|
|
105
|
-
// Also set up a direct subscription after loading is complete
|
|
106
337
|
useEffect(() => {
|
|
107
|
-
|
|
108
|
-
|
|
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 (
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
158
|
-
if (
|
|
159
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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-
|
|
250
|
-
<h4>
|
|
251
|
-
<i className="bi-person-plus-fill me-2" />
|
|
252
|
-
Connect to
|
|
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
|
-
{
|
|
255
|
-
<button className="btn btn-outline-secondary btn-sm" onClick={
|
|
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
|
-
{
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
577
|
+
{phase === "review" && (
|
|
578
|
+
<ReviewPanel
|
|
579
|
+
reviewCode={reviewCode}
|
|
580
|
+
submitting={submitting}
|
|
581
|
+
onConfirm={() => void handleConfirmReview()}
|
|
582
|
+
onEnterManual={handleEnterManual}
|
|
583
|
+
/>
|
|
336
584
|
)}
|
|
337
585
|
|
|
338
|
-
{
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
{
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
600
|
+
{phase === "expired" && (
|
|
601
|
+
<ExpiredPanel onNewInvite={startOffer} onEnterCode={handleEnterManual} />
|
|
602
|
+
)}
|
|
603
|
+
</div>
|
|
604
|
+
);
|
|
605
|
+
}
|
|
392
606
|
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
}
|