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