@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.
Files changed (93) hide show
  1. package/biome.json +2 -2
  2. package/dist/components/qr-scanner.d.ts +18 -0
  3. package/dist/components/qr-scanner.js +165 -0
  4. package/dist/components/router.d.ts +1 -1
  5. package/dist/components/router.js +1 -1
  6. package/dist/root-layout/root-layout-host.js +18 -19
  7. package/dist/root-layout/root-layout-host.test.js +14 -9
  8. package/dist/screens/account/account-screen.js +52 -1
  9. package/dist/screens/contacts/contact-list.js +28 -1
  10. package/dist/screens/contacts/index.d.ts +0 -2
  11. package/dist/screens/contacts/index.js +0 -2
  12. package/dist/screens/device-pairing/device-pairing.d.ts +2 -4
  13. package/dist/screens/device-pairing/device-pairing.js +127 -128
  14. package/dist/screens/device-pairing/device-pairing.test.js +51 -15
  15. package/dist/screens/device-pairing/index.d.ts +0 -1
  16. package/dist/screens/device-pairing/index.js +0 -1
  17. package/dist/screens/groups/group-details.js +2 -6
  18. package/dist/screens/groups/group-invite-panel.d.ts +14 -0
  19. package/dist/screens/groups/group-invite-panel.js +125 -0
  20. package/dist/screens/groups/group-members.js +9 -103
  21. package/dist/screens/invites/accept-invite.d.ts +11 -0
  22. package/dist/screens/invites/accept-invite.js +114 -0
  23. package/dist/screens/invites/index.d.ts +7 -0
  24. package/dist/screens/{join-group → invites}/index.js +8 -3
  25. package/dist/screens/invites/invite-capture.d.ts +34 -0
  26. package/dist/screens/invites/invite-capture.js +94 -0
  27. package/dist/screens/invites/invite-share.d.ts +15 -0
  28. package/dist/screens/invites/invite-share.js +40 -0
  29. package/dist/screens/invites/invites-ipc.d.ts +10 -0
  30. package/dist/screens/invites/invites-ipc.js +61 -0
  31. package/dist/screens/invites/invites-screen.d.ts +9 -0
  32. package/dist/screens/invites/invites-screen.js +36 -0
  33. package/dist/screens/invites/pending-invites-list.d.ts +29 -0
  34. package/dist/screens/invites/pending-invites-list.js +156 -0
  35. package/dist/screens/settings/settings-page.js +1 -1
  36. package/dist/screens/setup-user.js +4 -18
  37. package/dist/system-apps/index.d.ts +1 -1
  38. package/dist/system-apps/index.js +5 -5
  39. package/dist/system-apps/{join-group.app.d.ts → invites.app.d.ts} +1 -1
  40. package/dist/system-apps/invites.app.js +9 -0
  41. package/package.json +4 -3
  42. package/src/components/qr-scanner.tsx +180 -0
  43. package/src/components/router.tsx +1 -1
  44. package/src/root-layout/root-layout-host.test.tsx +18 -13
  45. package/src/root-layout/root-layout-host.tsx +23 -21
  46. package/src/screens/account/account-screen.tsx +89 -2
  47. package/src/screens/contacts/contact-list.tsx +64 -4
  48. package/src/screens/contacts/index.ts +0 -2
  49. package/src/screens/device-pairing/device-pairing.test.tsx +61 -16
  50. package/src/screens/device-pairing/device-pairing.tsx +283 -208
  51. package/src/screens/device-pairing/index.ts +0 -1
  52. package/src/screens/groups/group-details.tsx +1 -41
  53. package/src/screens/groups/group-invite-panel.tsx +316 -0
  54. package/src/screens/groups/group-members.tsx +15 -177
  55. package/src/screens/invites/accept-invite.tsx +271 -0
  56. package/src/screens/invites/index.ts +9 -0
  57. package/src/screens/invites/invite-capture.ts +107 -0
  58. package/src/screens/invites/invite-share.tsx +104 -0
  59. package/src/screens/invites/invites-ipc.ts +74 -0
  60. package/src/screens/invites/invites-screen.tsx +78 -0
  61. package/src/screens/invites/pending-invites-list.tsx +224 -0
  62. package/src/screens/settings/settings-page.tsx +2 -2
  63. package/src/screens/setup-user.tsx +29 -47
  64. package/src/system-apps/index.ts +3 -3
  65. package/src/system-apps/invites.app.ts +8 -0
  66. package/dist/screens/contacts/user-connect-invite.d.ts +0 -118
  67. package/dist/screens/contacts/user-connect-invite.js +0 -264
  68. package/dist/screens/contacts/user-connect-invite.test.d.ts +0 -1
  69. package/dist/screens/contacts/user-connect-invite.test.js +0 -215
  70. package/dist/screens/contacts/user-connect.d.ts +0 -8
  71. package/dist/screens/contacts/user-connect.js +0 -487
  72. package/dist/screens/contacts/user-connect.test.d.ts +0 -1
  73. package/dist/screens/contacts/user-connect.test.js +0 -291
  74. package/dist/screens/device-pairing/device-pairing-invite.d.ts +0 -10
  75. package/dist/screens/device-pairing/device-pairing-invite.js +0 -50
  76. package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +0 -1
  77. package/dist/screens/device-pairing/device-pairing-invite.test.js +0 -30
  78. package/dist/screens/groups/group-invite-listener.d.ts +0 -14
  79. package/dist/screens/groups/group-invite-listener.js +0 -93
  80. package/dist/screens/join-group/index.d.ts +0 -2
  81. package/dist/screens/join-group/join-group.d.ts +0 -12
  82. package/dist/screens/join-group/join-group.js +0 -102
  83. package/dist/system-apps/join-group.app.js +0 -9
  84. package/src/screens/contacts/user-connect-invite.test.ts +0 -256
  85. package/src/screens/contacts/user-connect-invite.ts +0 -332
  86. package/src/screens/contacts/user-connect.test.tsx +0 -382
  87. package/src/screens/contacts/user-connect.tsx +0 -883
  88. package/src/screens/device-pairing/device-pairing-invite.test.ts +0 -50
  89. package/src/screens/device-pairing/device-pairing-invite.ts +0 -55
  90. package/src/screens/groups/group-invite-listener.tsx +0 -282
  91. package/src/screens/join-group/index.ts +0 -4
  92. package/src/screens/join-group/join-group.tsx +0 -296
  93. 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/join-group";
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 { readUserConnectPendingIntent } from "../screens/contacts/user-connect-invite";
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
- null,
92
- "",
93
- `/?app#contacts/connect?code=${encodeURIComponent("ABCD-1234-EFGH")}`,
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("#contacts/connect");
99
- expect(readUserConnectPendingIntent()).toEqual({
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
- captureUserConnectInviteFromLocation,
26
- readUserConnectPendingIntent,
27
- } from "../screens/contacts/user-connect-invite";
28
- import { captureDevicePairingInviteFromLocation } from "../screens/device-pairing";
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
- captureDevicePairingInviteFromLocation();
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
- const captureInviteFromHashChange = () => {
59
- const previousHash = window.location.hash;
60
- captureDevicePairingInviteFromLocation();
61
- captureUserConnectInviteFromLocation();
62
- if (window.location.hash === previousHash) {
63
- return;
64
- }
65
- // Signed-out captures stay in sessionStorage until setup finishes.
66
- if (userIdRef.current) {
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
- const pendingInvite = readUserConnectPendingIntent();
109
- if (pendingInvite.ok || pendingInvite.reason !== "none") {
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
- <a href="#contacts/connect" className="btn btn-primary btn-sm">
134
- <i className="bi-person-plus me-1" />
135
- Connect to New User
136
- </a>
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("uses the host-owned invitation URL and cancels a source that resolves after unmount", async () => {
34
- let resolveStart!: (value: { state: DevicePairingState; invitationUrl: string }) => void;
35
- mockRpc.startSource.mockReturnValue(
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(<SourceDevicePairing onClose={jest.fn()} />);
40
+ const view = render(<DestinationDevicePairing onBack={jest.fn()} />);
41
41
 
42
- expect(mockRpc.startSource).toHaveBeenCalledWith();
42
+ expect(mockRpc.startDestination).toHaveBeenCalledWith({ deviceType: "browser" });
43
43
  view.unmount();
44
44
  resolveStart({
45
- state: { sessionId: "00mh0wlipkdbeaw8tests0001", role: "source", phase: "inviting" },
46
- invitationUrl: "https://peers.app/?app#pair=token",
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("keeps source cancellation available while waiting for remote approval", async () => {
57
- const onClose = jest.fn();
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: "00mh0wlipkdbeaw8tests0002",
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: "00mh0wlipkdbeaw8tests0003",
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()} />);