@peers-app/peers-ui 0.23.14 → 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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** Props for {@link QrScanner}. */
|
|
4
|
+
export interface QrScannerProps {
|
|
5
|
+
/** Called once with the decoded text of the first QR code seen; scanning stops after this. */
|
|
6
|
+
onScan: (text: string) => void;
|
|
7
|
+
/** Called when the camera cannot be opened. */
|
|
8
|
+
onError?: (message: string) => void;
|
|
9
|
+
/** Optional filter; return `false` to keep scanning past a code that is not ours. */
|
|
10
|
+
accept?: (text: string) => boolean;
|
|
11
|
+
/** Square edge length in CSS pixels. */
|
|
12
|
+
size?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type BarcodeDetectorLike = {
|
|
16
|
+
detect(source: ImageBitmapSource): Promise<Array<{ rawValue: string }>>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type BarcodeDetectorCtor = new (options?: { formats?: string[] }) => BarcodeDetectorLike;
|
|
20
|
+
|
|
21
|
+
function nativeDetector(): BarcodeDetectorLike | undefined {
|
|
22
|
+
const ctor = (globalThis as { BarcodeDetector?: BarcodeDetectorCtor }).BarcodeDetector;
|
|
23
|
+
if (!ctor) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return new ctor({ formats: ["qr_code"] });
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether this host can plausibly scan (secure context with a camera API). */
|
|
34
|
+
export function canScanQr(): boolean {
|
|
35
|
+
return (
|
|
36
|
+
typeof navigator !== "undefined" &&
|
|
37
|
+
Boolean(navigator.mediaDevices?.getUserMedia) &&
|
|
38
|
+
(typeof window === "undefined" || window.isSecureContext !== false)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Live camera QR scanner. Uses the platform `BarcodeDetector` when available and falls
|
|
44
|
+
* back to `jsqr` on a canvas. Stops the camera on unmount or after the first accepted code.
|
|
45
|
+
*/
|
|
46
|
+
export function QrScanner(props: QrScannerProps) {
|
|
47
|
+
const { onScan, onError, accept, size = 280 } = props;
|
|
48
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
49
|
+
const [status, setStatus] = useState<"starting" | "scanning" | "error">("starting");
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let cancelled = false;
|
|
53
|
+
let stream: MediaStream | undefined;
|
|
54
|
+
let frame = 0;
|
|
55
|
+
const canvas = document.createElement("canvas");
|
|
56
|
+
const detector = nativeDetector();
|
|
57
|
+
let jsqr: typeof import("jsqr").default | undefined;
|
|
58
|
+
|
|
59
|
+
const stop = () => {
|
|
60
|
+
cancelled = true;
|
|
61
|
+
if (frame) {
|
|
62
|
+
cancelAnimationFrame(frame);
|
|
63
|
+
}
|
|
64
|
+
for (const track of stream?.getTracks() ?? []) {
|
|
65
|
+
track.stop();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const deliver = (text: string) => {
|
|
70
|
+
if (cancelled || (accept && !accept(text))) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
stop();
|
|
74
|
+
onScan(text);
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const tick = async () => {
|
|
79
|
+
const video = videoRef.current;
|
|
80
|
+
if (cancelled || !video || video.readyState < 2) {
|
|
81
|
+
frame = requestAnimationFrame(() => void tick());
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
if (detector) {
|
|
86
|
+
const codes = await detector.detect(video);
|
|
87
|
+
for (const code of codes) {
|
|
88
|
+
if (deliver(code.rawValue)) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (jsqr) {
|
|
93
|
+
const width = video.videoWidth;
|
|
94
|
+
const height = video.videoHeight;
|
|
95
|
+
if (width && height) {
|
|
96
|
+
canvas.width = width;
|
|
97
|
+
canvas.height = height;
|
|
98
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
99
|
+
if (ctx) {
|
|
100
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
101
|
+
const image = ctx.getImageData(0, 0, width, height);
|
|
102
|
+
const result = jsqr(image.data, width, height, { inversionAttempts: "dontInvert" });
|
|
103
|
+
if (result?.data && deliver(result.data)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// A single bad frame is not fatal; keep scanning.
|
|
111
|
+
}
|
|
112
|
+
if (!cancelled) {
|
|
113
|
+
frame = requestAnimationFrame(() => void tick());
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
(async () => {
|
|
118
|
+
try {
|
|
119
|
+
if (!detector) {
|
|
120
|
+
jsqr = (await import("jsqr")).default;
|
|
121
|
+
}
|
|
122
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
123
|
+
video: { facingMode: { ideal: "environment" } },
|
|
124
|
+
audio: false,
|
|
125
|
+
});
|
|
126
|
+
if (cancelled) {
|
|
127
|
+
stop();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const video = videoRef.current;
|
|
131
|
+
if (!video) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
video.srcObject = stream;
|
|
135
|
+
await video.play();
|
|
136
|
+
setStatus("scanning");
|
|
137
|
+
frame = requestAnimationFrame(() => void tick());
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (cancelled) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
setStatus("error");
|
|
143
|
+
onError?.(
|
|
144
|
+
error instanceof DOMException && error.name === "NotAllowedError"
|
|
145
|
+
? "Camera access was denied."
|
|
146
|
+
: "Could not open the camera.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
|
|
151
|
+
return stop;
|
|
152
|
+
}, [onScan, onError, accept]);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
className="position-relative bg-black rounded overflow-hidden mx-auto"
|
|
157
|
+
style={{ width: size, height: size }}
|
|
158
|
+
>
|
|
159
|
+
<video
|
|
160
|
+
ref={videoRef}
|
|
161
|
+
muted
|
|
162
|
+
playsInline
|
|
163
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
164
|
+
aria-label="Camera preview"
|
|
165
|
+
/>
|
|
166
|
+
{status === "starting" ? (
|
|
167
|
+
<output className="position-absolute top-50 start-50 translate-middle text-light small">
|
|
168
|
+
Starting camera…
|
|
169
|
+
</output>
|
|
170
|
+
) : null}
|
|
171
|
+
{status === "scanning" ? (
|
|
172
|
+
<div
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
className="position-absolute top-50 start-50 translate-middle border border-2 border-light rounded"
|
|
175
|
+
style={{ width: "60%", height: "60%", opacity: 0.7 }}
|
|
176
|
+
/>
|
|
177
|
+
) : null}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -25,7 +25,7 @@ import { ThreadMessageList } from "./messages/thread-message-list";
|
|
|
25
25
|
import "../screens/console-logs/console-logs-list";
|
|
26
26
|
import "../screens/groups";
|
|
27
27
|
import "../screens/contacts";
|
|
28
|
-
import "../screens/
|
|
28
|
+
import "../screens/invites";
|
|
29
29
|
import "../screens/network-viewer";
|
|
30
30
|
import "../screens/data-explorer";
|
|
31
31
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { fireEvent, render } from "@testing-library/react";
|
|
2
2
|
import { mainContentPath } from "../globals";
|
|
3
|
-
import {
|
|
3
|
+
import { ACCEPT_INVITE_PATH, peekPendingInvite } from "../screens/invites/invite-capture";
|
|
4
4
|
import { RootLayoutHost } from "./root-layout-host";
|
|
5
5
|
|
|
6
|
+
const TOKEN = "eyJhbGciOiJub25lIn0-_abcdefghijklmnopqrstuvwxyz0123456789";
|
|
7
|
+
|
|
6
8
|
jest.mock("@peers-app/peers-sdk", () => {
|
|
7
9
|
const actual = jest.requireActual<typeof import("@peers-app/peers-sdk")>("@peers-app/peers-sdk");
|
|
8
10
|
return {
|
|
@@ -87,20 +89,23 @@ describe("RootLayoutHost", () => {
|
|
|
87
89
|
|
|
88
90
|
it("captures and sanitizes invite links opened after startup", () => {
|
|
89
91
|
render(<RootLayoutHost />);
|
|
90
|
-
window.history.replaceState(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
92
|
+
window.history.replaceState(null, "", `/?app#invite=${TOKEN}`);
|
|
93
|
+
|
|
94
|
+
fireEvent(window, new HashChangeEvent("hashchange"));
|
|
95
|
+
|
|
96
|
+
expect(window.location.hash).toBe("");
|
|
97
|
+
expect(peekPendingInvite()).toBe(TOKEN);
|
|
98
|
+
expect(mainContentPath).toHaveBeenCalledWith(ACCEPT_INVITE_PATH);
|
|
99
|
+
expect(mainContentPath).not.toHaveBeenCalledWith(expect.stringContaining(TOKEN));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("ignores ordinary hash routes", () => {
|
|
103
|
+
render(<RootLayoutHost />);
|
|
104
|
+
window.history.replaceState(null, "", "/?app#groups");
|
|
95
105
|
|
|
96
106
|
fireEvent(window, new HashChangeEvent("hashchange"));
|
|
97
107
|
|
|
98
|
-
expect(window.location.hash).toBe("#
|
|
99
|
-
expect(
|
|
100
|
-
ok: true,
|
|
101
|
-
intent: expect.objectContaining({ code: "ABCD1234EFGH" }),
|
|
102
|
-
});
|
|
103
|
-
expect(mainContentPath).toHaveBeenCalledWith("contacts/connect");
|
|
104
|
-
expect(mainContentPath).not.toHaveBeenCalledWith(expect.stringContaining("code="));
|
|
108
|
+
expect(window.location.hash).toBe("#groups");
|
|
109
|
+
expect(peekPendingInvite()).toBeUndefined();
|
|
105
110
|
});
|
|
106
111
|
});
|
|
@@ -22,10 +22,12 @@ import { CommandPaletteOverlay } from "../command-palette/command-palette-ui";
|
|
|
22
22
|
import { loadGlobals, mainContentPath } from "../globals";
|
|
23
23
|
import { useObservable, usePromise } from "../hooks";
|
|
24
24
|
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
ACCEPT_INVITE_PATH,
|
|
26
|
+
captureInviteFromLocation,
|
|
27
|
+
inviteCaptured,
|
|
28
|
+
listenForOpenUrlInvites,
|
|
29
|
+
peekPendingInvite,
|
|
30
|
+
} from "../screens/invites/invite-capture";
|
|
29
31
|
import { colorMode } from "../screens/settings/color-mode-dropdown";
|
|
30
32
|
import { SetupUser } from "../screens/setup-user";
|
|
31
33
|
import { WelcomeModal } from "../screens/welcome-modal";
|
|
@@ -42,8 +44,7 @@ import { selectedRootLayoutId } from "./root-layout-state";
|
|
|
42
44
|
*/
|
|
43
45
|
export function RootLayoutHost() {
|
|
44
46
|
useState(() => {
|
|
45
|
-
|
|
46
|
-
captureUserConnectInviteFromLocation();
|
|
47
|
+
captureInviteFromLocation();
|
|
47
48
|
return true;
|
|
48
49
|
});
|
|
49
50
|
|
|
@@ -55,20 +56,22 @@ export function RootLayoutHost() {
|
|
|
55
56
|
userIdRef.current = userId;
|
|
56
57
|
|
|
57
58
|
useEffect(() => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
mainContentPath("contacts/connect");
|
|
59
|
+
// Tokens arriving after startup: a hash change in the browser/PWA, or a peers:// open
|
|
60
|
+
// forwarded by the Electron main process. Signed-out captures stay stashed until setup
|
|
61
|
+
// finishes; signed-in captures route straight to the Accept screen.
|
|
62
|
+
const onHashChange = () => captureInviteFromLocation();
|
|
63
|
+
window.addEventListener("hashchange", onHashChange);
|
|
64
|
+
const stopOpenUrl = listenForOpenUrlInvites();
|
|
65
|
+
const sub = inviteCaptured.subscribe(() => {
|
|
66
|
+
if (userIdRef.current && peekPendingInvite()) {
|
|
67
|
+
mainContentPath(ACCEPT_INVITE_PATH);
|
|
68
68
|
}
|
|
69
|
+
});
|
|
70
|
+
return () => {
|
|
71
|
+
window.removeEventListener("hashchange", onHashChange);
|
|
72
|
+
stopOpenUrl();
|
|
73
|
+
sub.dispose();
|
|
69
74
|
};
|
|
70
|
-
window.addEventListener("hashchange", captureInviteFromHashChange);
|
|
71
|
-
return () => window.removeEventListener("hashchange", captureInviteFromHashChange);
|
|
72
75
|
}, []);
|
|
73
76
|
|
|
74
77
|
if (userId === undefined) {
|
|
@@ -105,9 +108,8 @@ function RootLayoutBootstrap(props: { userId: string }) {
|
|
|
105
108
|
recentlyUsedApps.loadingPromise,
|
|
106
109
|
hasShownWelcomeModal.loadingPromise,
|
|
107
110
|
]);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
mainContentPath("contacts/connect");
|
|
111
|
+
if (peekPendingInvite()) {
|
|
112
|
+
mainContentPath(ACCEPT_INVITE_PATH);
|
|
111
113
|
}
|
|
112
114
|
return true;
|
|
113
115
|
});
|
|
@@ -1,11 +1,96 @@
|
|
|
1
|
-
import { rpcServerCalls } from "@peers-app/peers-sdk";
|
|
2
|
-
import { useState } from "react";
|
|
1
|
+
import { type ICreatedInvite, rpcServerCalls } from "@peers-app/peers-sdk";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { LoadingIndicator } from "../../components/loading-indicator";
|
|
3
4
|
import { Tooltip } from "../../components/tooltip";
|
|
4
5
|
import { useObservable } from "../../hooks";
|
|
5
6
|
import {
|
|
6
7
|
persistPeersServicesRegistration,
|
|
7
8
|
registrationStatusPvar,
|
|
8
9
|
} from "../../peers-services-registration";
|
|
10
|
+
import { InviteShare } from "../invites/invite-share";
|
|
11
|
+
import { inviteTools } from "../invites/invites-ipc";
|
|
12
|
+
|
|
13
|
+
const PROFILE_QR_INFO = `### Your profile QR
|
|
14
|
+
|
|
15
|
+
Anyone who scans this (or opens the link) can send you a contact request. Requests show up under **Contacts → Pending** and nothing happens until you confirm.
|
|
16
|
+
|
|
17
|
+
### Regenerate
|
|
18
|
+
|
|
19
|
+
Regenerating invalidates this QR **and every other invite link you have issued**. Use it if the QR leaked somewhere you did not intend.`;
|
|
20
|
+
|
|
21
|
+
/** Reusable profile invite with a regenerate control that bumps the invite epoch. */
|
|
22
|
+
function ProfileInviteSection() {
|
|
23
|
+
const [invite, setInvite] = useState<ICreatedInvite | undefined>(undefined);
|
|
24
|
+
const [busy, setBusy] = useState(false);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
void inviteTools()
|
|
30
|
+
.then((tools) => tools.createContactInvite({ profile: true }))
|
|
31
|
+
.then(({ invite: created }) => {
|
|
32
|
+
if (!cancelled) setInvite(created);
|
|
33
|
+
})
|
|
34
|
+
.catch((err) => {
|
|
35
|
+
if (!cancelled) setError((err as Error).message || "Could not load your profile QR.");
|
|
36
|
+
});
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true;
|
|
39
|
+
};
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const regenerate = async () => {
|
|
43
|
+
if (
|
|
44
|
+
!confirm(
|
|
45
|
+
"Regenerate your profile QR? Every invite link you have issued so far will stop working.",
|
|
46
|
+
)
|
|
47
|
+
) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setBusy(true);
|
|
51
|
+
setError(null);
|
|
52
|
+
try {
|
|
53
|
+
const tools = await inviteTools();
|
|
54
|
+
const { invite: created } = await tools.regenerateProfileInvite();
|
|
55
|
+
setInvite(created);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
setError((err as Error).message || "Could not regenerate.");
|
|
58
|
+
} finally {
|
|
59
|
+
setBusy(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="card mb-3">
|
|
65
|
+
<div className="card-body">
|
|
66
|
+
<h5 className="card-title d-flex align-items-center gap-2">
|
|
67
|
+
Profile QR
|
|
68
|
+
<Tooltip markdownContent={PROFILE_QR_INFO} />
|
|
69
|
+
</h5>
|
|
70
|
+
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
|
|
71
|
+
{invite ? (
|
|
72
|
+
<InviteShare
|
|
73
|
+
invite={invite}
|
|
74
|
+
description="Let people connect with you by scanning this or opening the link. You confirm each request."
|
|
75
|
+
shareText="Connect with me on Peers:"
|
|
76
|
+
/>
|
|
77
|
+
) : !error ? (
|
|
78
|
+
<LoadingIndicator />
|
|
79
|
+
) : null}
|
|
80
|
+
<div className="text-center mt-3">
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
className="btn btn-outline-danger btn-sm"
|
|
84
|
+
onClick={() => void regenerate()}
|
|
85
|
+
disabled={busy || !invite}
|
|
86
|
+
>
|
|
87
|
+
{busy ? "Regenerating…" : "Regenerate (revokes all invites)"}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
9
94
|
|
|
10
95
|
const INFO_TEXT = `### What does registering do?
|
|
11
96
|
|
|
@@ -81,6 +166,8 @@ export function AccountScreen() {
|
|
|
81
166
|
</div>
|
|
82
167
|
)}
|
|
83
168
|
|
|
169
|
+
<ProfileInviteSection />
|
|
170
|
+
|
|
84
171
|
<div className="card">
|
|
85
172
|
<div className="card-body">
|
|
86
173
|
<h5 className="card-title d-flex align-items-center gap-2">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getUserContext,
|
|
3
|
+
type ICreatedInvite,
|
|
3
4
|
type ICursorIterable,
|
|
4
5
|
type IUserWithSource,
|
|
5
6
|
observable,
|
|
@@ -17,8 +18,53 @@ import { TrustLevelBadge } from "../../components/trust-level-badge";
|
|
|
17
18
|
import { mainContentPath } from "../../globals";
|
|
18
19
|
import { useObservable } from "../../hooks";
|
|
19
20
|
import { registerInternalPeersUI } from "../../ui-router/ui-loader";
|
|
21
|
+
import { ACCEPT_INVITE_PATH } from "../invites/invite-capture";
|
|
22
|
+
import { InviteShare } from "../invites/invite-share";
|
|
23
|
+
import { inviteTools } from "../invites/invites-ipc";
|
|
24
|
+
import { PendingInvitesList } from "../invites/pending-invites-list";
|
|
25
|
+
|
|
26
|
+
/** Issues a one-off contact invite and shows it as QR/link until dismissed. */
|
|
27
|
+
function InviteContactPanel({ onClose }: { onClose: () => void }) {
|
|
28
|
+
const [invite, setInvite] = useState<ICreatedInvite | undefined>(undefined);
|
|
29
|
+
const [error, setError] = useState("");
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
void inviteTools()
|
|
34
|
+
.then((tools) => tools.createContactInvite({}))
|
|
35
|
+
.then(({ invite: created }) => {
|
|
36
|
+
if (!cancelled) setInvite(created);
|
|
37
|
+
})
|
|
38
|
+
.catch((err) => {
|
|
39
|
+
if (!cancelled) setError((err as Error).message || "Could not create an invite.");
|
|
40
|
+
});
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="card mb-3">
|
|
48
|
+
<div className="card-body">
|
|
49
|
+
<h6 className="card-title">Invite a contact</h6>
|
|
50
|
+
{error ? <div className="alert alert-danger py-2">{error}</div> : null}
|
|
51
|
+
{invite ? (
|
|
52
|
+
<InviteShare
|
|
53
|
+
invite={invite}
|
|
54
|
+
description="Send this link or show the QR. When they accept, you are connected — no need to be online at the same time."
|
|
55
|
+
shareText="Connect with me on Peers:"
|
|
56
|
+
onDone={onClose}
|
|
57
|
+
/>
|
|
58
|
+
) : !error ? (
|
|
59
|
+
<LoadingIndicator />
|
|
60
|
+
) : null}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
20
65
|
|
|
21
66
|
export function ContactList() {
|
|
67
|
+
const [inviting, setInviting] = useState(false);
|
|
22
68
|
const [searchTextObs] = useState(() => observable(""));
|
|
23
69
|
const [searchText] = useObservable(searchTextObs);
|
|
24
70
|
const [cursorObs] = useState(() => observable<ICursorIterable<IUserWithSource> | undefined>());
|
|
@@ -130,12 +176,26 @@ export function ContactList() {
|
|
|
130
176
|
<i className="bi-person-fill-check me-2" />
|
|
131
177
|
Contacts
|
|
132
178
|
</h4>
|
|
133
|
-
<
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
179
|
+
<div className="d-flex gap-2">
|
|
180
|
+
<a href={`#${ACCEPT_INVITE_PATH}`} className="btn btn-outline-primary btn-sm">
|
|
181
|
+
<i className="bi-qr-code-scan me-1" />
|
|
182
|
+
Accept invite
|
|
183
|
+
</a>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
className="btn btn-primary btn-sm"
|
|
187
|
+
onClick={() => setInviting(true)}
|
|
188
|
+
disabled={inviting}
|
|
189
|
+
>
|
|
190
|
+
<i className="bi-person-plus me-1" />
|
|
191
|
+
Invite
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
137
194
|
</div>
|
|
138
195
|
|
|
196
|
+
{inviting ? <InviteContactPanel onClose={() => setInviting(false)} /> : null}
|
|
197
|
+
<PendingInvitesList type="contact" hideWhenEmpty title="Pending contact invites" />
|
|
198
|
+
|
|
139
199
|
<div className="input-group mb-3">
|
|
140
200
|
<Input
|
|
141
201
|
value={searchTextObs}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// Import all contact screen components to ensure they register their routes
|
|
2
2
|
import "./contact-list";
|
|
3
3
|
import "./contact-details";
|
|
4
|
-
import "./user-connect";
|
|
5
4
|
|
|
6
5
|
export * from "./contact-details";
|
|
7
6
|
export * from "./contact-list";
|
|
8
|
-
export * from "./user-connect";
|
|
@@ -30,20 +30,22 @@ describe("device pairing UI lifecycle", () => {
|
|
|
30
30
|
mockRpc.cancel.mockResolvedValue({ phase: "idle" });
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
it("
|
|
34
|
-
let resolveStart!: (value:
|
|
35
|
-
mockRpc.
|
|
33
|
+
it("starts the destination on mount and cancels when it resolves after unmount", async () => {
|
|
34
|
+
let resolveStart!: (value: DevicePairingState) => void;
|
|
35
|
+
mockRpc.startDestination.mockReturnValue(
|
|
36
36
|
new Promise((resolve) => {
|
|
37
37
|
resolveStart = resolve;
|
|
38
38
|
}),
|
|
39
39
|
);
|
|
40
|
-
const view = render(<
|
|
40
|
+
const view = render(<DestinationDevicePairing onBack={jest.fn()} />);
|
|
41
41
|
|
|
42
|
-
expect(mockRpc.
|
|
42
|
+
expect(mockRpc.startDestination).toHaveBeenCalledWith({ deviceType: "browser" });
|
|
43
43
|
view.unmount();
|
|
44
44
|
resolveStart({
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
sessionId: "00mh0wlipkdbeaw8tests0001",
|
|
46
|
+
role: "destination",
|
|
47
|
+
phase: "showing-code",
|
|
48
|
+
code: "7-aardvark-adroitness-absurd",
|
|
47
49
|
});
|
|
48
50
|
|
|
49
51
|
await waitFor(() => {
|
|
@@ -53,25 +55,67 @@ describe("device pairing UI lifecycle", () => {
|
|
|
53
55
|
});
|
|
54
56
|
});
|
|
55
57
|
|
|
56
|
-
it("
|
|
57
|
-
|
|
58
|
+
it("shows the destination code and QR while waiting for a source", () => {
|
|
59
|
+
mockPairingState = {
|
|
60
|
+
sessionId: "00mh0wlipkdbeaw8tests0004",
|
|
61
|
+
role: "destination",
|
|
62
|
+
phase: "showing-code",
|
|
63
|
+
code: "7-aardvark-adroitness-absurd",
|
|
64
|
+
notice: "Pairing code did not match. Try the new code.",
|
|
65
|
+
expiresAt: Date.now() + 60_000,
|
|
66
|
+
};
|
|
67
|
+
mockRpc.startDestination.mockResolvedValue(mockPairingState);
|
|
68
|
+
const view = render(<DestinationDevicePairing onBack={jest.fn()} />);
|
|
69
|
+
|
|
70
|
+
expect(view.getByLabelText("Pairing code")).toHaveTextContent("7-aardvark-adroitness-absurd");
|
|
71
|
+
expect(view.getByLabelText("Pairing code QR")).toBeInTheDocument();
|
|
72
|
+
expect(view.getByText("Pairing code did not match. Try the new code.")).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("sends the typed code to the host and only approves from the source", async () => {
|
|
76
|
+
mockRpc.startSource.mockResolvedValue({
|
|
77
|
+
sessionId: "00mh0wlipkdbeaw8tests0002",
|
|
78
|
+
role: "source",
|
|
79
|
+
phase: "connecting",
|
|
80
|
+
});
|
|
81
|
+
const view = render(<SourceDevicePairing onClose={jest.fn()} />);
|
|
82
|
+
|
|
83
|
+
fireEvent.change(view.getByLabelText("Pairing code"), {
|
|
84
|
+
target: { value: " 7 aardvark adroitness absurd " },
|
|
85
|
+
});
|
|
86
|
+
fireEvent.click(view.getByRole("button", { name: "Connect" }));
|
|
87
|
+
await waitFor(() =>
|
|
88
|
+
expect(mockRpc.startSource).toHaveBeenCalledWith({ code: "7 aardvark adroitness absurd" }),
|
|
89
|
+
);
|
|
90
|
+
|
|
58
91
|
mockPairingState = {
|
|
59
92
|
sessionId: "00mh0wlipkdbeaw8tests0002",
|
|
60
93
|
role: "source",
|
|
61
94
|
phase: "waiting-for-approval",
|
|
95
|
+
remoteDevice: { deviceName: "New phone", deviceType: "mobile" },
|
|
96
|
+
};
|
|
97
|
+
view.rerender(<SourceDevicePairing onClose={jest.fn()} />);
|
|
98
|
+
expect(view.getByText("New phone (mobile)")).toBeInTheDocument();
|
|
99
|
+
fireEvent.click(view.getByRole("button", { name: "Approve" }));
|
|
100
|
+
await waitFor(() =>
|
|
101
|
+
expect(mockRpc.approve).toHaveBeenCalledWith({ sessionId: "00mh0wlipkdbeaw8tests0002" }),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps source cancellation available while transferring", async () => {
|
|
106
|
+
const onClose = jest.fn();
|
|
107
|
+
mockPairingState = {
|
|
108
|
+
sessionId: "00mh0wlipkdbeaw8tests0003",
|
|
109
|
+
role: "source",
|
|
110
|
+
phase: "transferring",
|
|
62
111
|
};
|
|
63
|
-
mockRpc.startSource.mockResolvedValue({
|
|
64
|
-
state: mockPairingState,
|
|
65
|
-
invitationUrl: "https://peers.app/?app#pair=token",
|
|
66
|
-
});
|
|
67
112
|
const view = render(<SourceDevicePairing onClose={onClose} />);
|
|
68
|
-
await waitFor(() => expect(mockRpc.startSource).toHaveBeenCalled());
|
|
69
113
|
|
|
70
114
|
fireEvent.click(view.getByRole("button", { name: "Cancel" }));
|
|
71
115
|
|
|
72
116
|
await waitFor(() => {
|
|
73
117
|
expect(mockRpc.cancel).toHaveBeenCalledWith({
|
|
74
|
-
sessionId: "
|
|
118
|
+
sessionId: "00mh0wlipkdbeaw8tests0003",
|
|
75
119
|
});
|
|
76
120
|
expect(onClose).toHaveBeenCalled();
|
|
77
121
|
});
|
|
@@ -80,10 +124,11 @@ describe("device pairing UI lifecycle", () => {
|
|
|
80
124
|
it("does not clear the destination success reload when Done unmounts the flow", () => {
|
|
81
125
|
jest.useFakeTimers();
|
|
82
126
|
mockPairingState = {
|
|
83
|
-
sessionId: "
|
|
127
|
+
sessionId: "00mh0wlipkdbeaw8tests0005",
|
|
84
128
|
role: "destination",
|
|
85
129
|
phase: "complete",
|
|
86
130
|
};
|
|
131
|
+
mockRpc.startDestination.mockResolvedValue(mockPairingState);
|
|
87
132
|
const setTimeoutSpy = jest.spyOn(window, "setTimeout");
|
|
88
133
|
const clearTimeoutSpy = jest.spyOn(window, "clearTimeout");
|
|
89
134
|
const view = render(<DestinationDevicePairing onBack={jest.fn()} />);
|