@peers-app/peers-ui 0.23.14 → 0.25.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
@@ -1,382 +0,0 @@
1
- import type { UserConnectStatus } from "@peers-app/peers-sdk";
2
- import { TrustLevel } from "@peers-app/peers-sdk";
3
- import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
4
- import { mainContentPath } from "../../globals";
5
- import { UserConnect } from "./user-connect";
6
- import {
7
- buildUserConnectInviteUrl,
8
- readUserConnectPendingIntent,
9
- writeUserConnectPendingIntent,
10
- } from "./user-connect-invite";
11
-
12
- interface MockObservable<T> {
13
- (): T;
14
- (next: T): T;
15
- subscribe: (listener: () => void) => { dispose: () => void };
16
- loadingPromise: Promise<void>;
17
- reset: (next: T) => void;
18
- }
19
-
20
- jest.mock("../../globals", () => ({
21
- mainContentPath: jest.fn(),
22
- }));
23
-
24
- jest.mock("../../ui-router/ui-loader", () => ({
25
- registerInternalPeersUI: jest.fn(),
26
- }));
27
-
28
- jest.mock("../../hooks", () => {
29
- const React = jest.requireActual<typeof import("react")>("react");
30
- return {
31
- useObservable: <T,>(observable: MockObservable<T>) => {
32
- const [value, setValue] = React.useState(observable());
33
- React.useEffect(() => {
34
- const subscription = observable.subscribe(() => setValue(observable()));
35
- return () => subscription.dispose();
36
- }, [observable]);
37
- return [value, observable] as const;
38
- },
39
- };
40
- });
41
-
42
- jest.mock("@peers-app/peers-sdk", () => {
43
- const actual = jest.requireActual<typeof import("@peers-app/peers-sdk")>("@peers-app/peers-sdk");
44
-
45
- const createObservable = <T,>(initial: T): MockObservable<T> => {
46
- let value = initial;
47
- const listeners = new Set<() => void>();
48
- const observable = jest.fn((next?: T) => {
49
- if (next !== undefined) {
50
- value = next as T;
51
- for (const listener of listeners) listener();
52
- }
53
- return value;
54
- }) as unknown as MockObservable<T>;
55
- observable.subscribe = (listener) => {
56
- listeners.add(listener);
57
- return { dispose: () => listeners.delete(listener) };
58
- };
59
- observable.loadingPromise = Promise.resolve();
60
- observable.reset = (next) => {
61
- value = next;
62
- for (const listener of listeners) listener();
63
- };
64
- return observable;
65
- };
66
-
67
- const idle: UserConnectStatus = { kind: "idle" };
68
- const status = createObservable<UserConnectStatus>(idle);
69
- const offer = createObservable("");
70
- const answer = createObservable("");
71
- const remoteUser = {
72
- userId: "remote-user",
73
- name: "Ada",
74
- publicKey: "remote-public-key",
75
- publicBoxKey: "remote-public-box-key",
76
- };
77
-
78
- return {
79
- ...actual,
80
- copyToClipboard: jest.fn(async () => true),
81
- Devices: jest.fn(() => ({
82
- get: jest.fn(async () => ({ deviceId: "remote-device", userId: remoteUser.userId })),
83
- })),
84
- generateConnectionCode: jest.fn(() => ({
85
- code: "ABCD1234EFGH",
86
- alias: "ABCD",
87
- secret: "1234EFGH",
88
- })),
89
- getMe: jest.fn(async () => ({
90
- userId: "local-user",
91
- name: "Grace",
92
- publicKey: "local-public-key",
93
- publicBoxKey: "local-public-box-key",
94
- })),
95
- getUserContext: jest.fn(async () => ({
96
- userDataContext: {},
97
- deviceId: () => "local-device",
98
- })),
99
- rpcServerCalls: {
100
- ...actual.rpcServerCalls,
101
- listConnectedDevices: jest.fn(async () => [
102
- {
103
- deviceId: "route-device",
104
- userId: "route-user",
105
- connectionId: "route-connection",
106
- trustLevel: 0,
107
- verified: true,
108
- },
109
- ]),
110
- },
111
- setUserTrustLevel: jest.fn(async () => undefined),
112
- Users: jest.fn(() => ({
113
- get: jest.fn(async () => remoteUser),
114
- })),
115
- userConnectCodeAnswer: answer,
116
- userConnectCodeOffer: offer,
117
- userConnectStatus: status,
118
- };
119
- });
120
-
121
- const sdk = jest.requireMock("@peers-app/peers-sdk") as {
122
- copyToClipboard: jest.Mock;
123
- Devices: jest.Mock;
124
- setUserTrustLevel: jest.Mock;
125
- userConnectCodeAnswer: MockObservable<string> & jest.Mock;
126
- userConnectCodeOffer: MockObservable<string> & jest.Mock;
127
- userConnectStatus: MockObservable<UserConnectStatus> & jest.Mock;
128
- };
129
-
130
- const validCode = "ABCD1234EFGH";
131
-
132
- function resetMocks(): void {
133
- sessionStorage.clear();
134
- window.history.replaceState(null, "", "/");
135
- sdk.userConnectStatus.reset({ kind: "idle" });
136
- sdk.userConnectCodeOffer.reset("");
137
- sdk.userConnectCodeAnswer.reset("");
138
- jest.clearAllMocks();
139
- }
140
-
141
- describe("UserConnect", () => {
142
- beforeEach(() => {
143
- resetMocks();
144
- });
145
-
146
- afterEach(() => {
147
- jest.useRealTimers();
148
- Reflect.deleteProperty(navigator, "share");
149
- });
150
-
151
- it("starts with a QR invite and readable fallback code", async () => {
152
- render(<UserConnect />);
153
-
154
- expect(
155
- await screen.findByText("Ask the other person to scan this with their camera"),
156
- ).toBeVisible();
157
- expect(screen.getByLabelText("Connection QR code")).toBeVisible();
158
- expect(screen.getByLabelText("Connection code")).toHaveValue("ABCD-1234-EFGH");
159
- expect(sdk.userConnectCodeOffer).toHaveBeenCalledWith(validCode);
160
- });
161
-
162
- it("opens a captured inbound invite in review without auto-submitting", async () => {
163
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
164
-
165
- render(<UserConnect />);
166
-
167
- expect(await screen.findByText("You opened a connection invite")).toBeVisible();
168
- expect(screen.getByText("ABCD-1234-EFGH")).toBeVisible();
169
- expect(sdk.userConnectCodeAnswer).not.toHaveBeenCalledWith(validCode);
170
- });
171
-
172
- it("keeps an unconfirmed invite available across a screen remount", async () => {
173
- window.history.replaceState(null, "", "/#contacts/connect");
174
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
175
- const firstRender = render(<UserConnect />);
176
- expect(await screen.findByText("You opened a connection invite")).toBeVisible();
177
-
178
- firstRender.unmount();
179
- render(<UserConnect />);
180
-
181
- expect(await screen.findByText("You opened a connection invite")).toBeVisible();
182
- expect(screen.getByText("ABCD-1234-EFGH")).toBeVisible();
183
- });
184
-
185
- it("clears an unconfirmed invite when navigating away", async () => {
186
- window.history.replaceState(null, "", "/#contacts/connect");
187
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
188
- const rendered = render(<UserConnect />);
189
- expect(await screen.findByText("You opened a connection invite")).toBeVisible();
190
-
191
- window.history.replaceState(null, "", "/#contacts");
192
- rendered.unmount();
193
-
194
- expect(readUserConnectPendingIntent()).toEqual({ ok: false, reason: "none" });
195
- });
196
-
197
- it("captures an invite opened while the screen is already mounted", async () => {
198
- render(<UserConnect />);
199
- await screen.findByText("Ask the other person to scan this with their camera");
200
-
201
- window.history.replaceState(
202
- null,
203
- "",
204
- `/?app#contacts/connect?code=${encodeURIComponent("ABCD-1234-EFGH")}`,
205
- );
206
- fireEvent(window, new HashChangeEvent("hashchange"));
207
-
208
- expect(await screen.findByText("You opened a connection invite")).toBeVisible();
209
- expect(screen.getByText("ABCD-1234-EFGH")).toBeVisible();
210
- expect(window.location.hash).toBe("#contacts/connect");
211
- expect(sdk.userConnectCodeOffer).toHaveBeenCalledWith("");
212
- expect(sdk.userConnectCodeAnswer).not.toHaveBeenCalledWith(validCode);
213
- });
214
-
215
- it("copies the complete invite link", async () => {
216
- render(<UserConnect />);
217
- fireEvent.click(await screen.findByRole("button", { name: "Copy link" }));
218
-
219
- await waitFor(() => {
220
- expect(sdk.copyToClipboard).toHaveBeenCalledWith(buildUserConnectInviteUrl(validCode));
221
- });
222
- expect((await screen.findAllByText("Link copied")).length).toBeGreaterThan(0);
223
- });
224
-
225
- it("shares the complete invite through the Web Share API", async () => {
226
- const share = jest.fn(async () => undefined);
227
- Object.defineProperty(navigator, "share", {
228
- configurable: true,
229
- value: share,
230
- });
231
- render(<UserConnect />);
232
-
233
- fireEvent.click(await screen.findByRole("button", { name: "Share link" }));
234
-
235
- await waitFor(() => {
236
- expect(share).toHaveBeenCalledWith(
237
- expect.objectContaining({
238
- title: "Connect on Peers",
239
- url: buildUserConnectInviteUrl(validCode),
240
- text: expect.stringContaining("expires in 10 minutes"),
241
- }),
242
- );
243
- });
244
- });
245
-
246
- it("stops waiting when the invite expires", async () => {
247
- jest.useFakeTimers();
248
- jest.setSystemTime(new Date("2026-07-16T12:00:00Z"));
249
- render(<UserConnect />);
250
-
251
- await act(async () => {
252
- jest.advanceTimersByTime(600_000);
253
- });
254
-
255
- expect(screen.getByText("Invite expired")).toBeVisible();
256
- expect(sdk.userConnectCodeOffer).toHaveBeenCalledWith("");
257
- });
258
-
259
- it("submits an inbound invite only once on a double click", async () => {
260
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
261
- render(<UserConnect />);
262
- const connect = await screen.findByRole("button", { name: "Connect" });
263
-
264
- fireEvent.click(connect);
265
- fireEvent.click(connect);
266
-
267
- expect(
268
- sdk.userConnectCodeAnswer.mock.calls.filter(([code]) => code === validCode),
269
- ).toHaveLength(1);
270
- expect(readUserConnectPendingIntent()).toEqual({ ok: false, reason: "none" });
271
- });
272
-
273
- it("rechecks invite expiry before confirming review", async () => {
274
- jest.useFakeTimers();
275
- jest.setSystemTime(new Date("2026-07-16T12:00:00Z"));
276
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
277
- render(<UserConnect />);
278
- const connect = await screen.findByRole("button", { name: "Connect" });
279
-
280
- jest.setSystemTime(new Date("2026-07-16T12:10:00Z"));
281
- fireEvent.click(connect);
282
-
283
- expect(screen.getByText("Invite expired")).toBeVisible();
284
- expect(sdk.userConnectCodeAnswer).not.toHaveBeenCalledWith(validCode);
285
- });
286
-
287
- it("does not treat the initial idle status as a canceled connection", async () => {
288
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
289
- render(<UserConnect />);
290
-
291
- fireEvent.click(await screen.findByRole("button", { name: "Connect" }));
292
-
293
- expect(screen.getByText("Connecting…")).toBeVisible();
294
- expect(screen.queryByText("Connection canceled")).not.toBeInTheDocument();
295
- });
296
-
297
- it("leaves connecting when the answer expires back to idle", async () => {
298
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
299
- render(<UserConnect />);
300
- fireEvent.click(await screen.findByRole("button", { name: "Connect" }));
301
-
302
- act(() => {
303
- sdk.userConnectCodeAnswer("");
304
- sdk.userConnectStatus({ kind: "idle" });
305
- });
306
-
307
- expect(await screen.findByText("Invite expired")).toBeVisible();
308
- });
309
-
310
- it("reports cancellation when an active protocol resets to idle", async () => {
311
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
312
- render(<UserConnect />);
313
- fireEvent.click(await screen.findByRole("button", { name: "Connect" }));
314
-
315
- act(() => {
316
- sdk.userConnectStatus({ kind: "connecting", attempt: 1 });
317
- });
318
- act(() => {
319
- sdk.userConnectStatus({ kind: "idle" });
320
- });
321
-
322
- expect(await screen.findByText("Connection canceled")).toBeVisible();
323
- });
324
-
325
- it("renders actionable structured errors", async () => {
326
- render(<UserConnect />);
327
-
328
- act(() => {
329
- sdk.userConnectStatus({ kind: "error", code: "no-route" });
330
- });
331
-
332
- expect(await screen.findByText("This device isn’t connected yet")).toBeVisible();
333
- expect(
334
- screen.getByText(
335
- "Connect this device to the internet or another Peers device, then try again.",
336
- ),
337
- ).toBeVisible();
338
- });
339
-
340
- it("trusts only when the primary success action is selected", async () => {
341
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
342
- render(<UserConnect />);
343
- fireEvent.click(await screen.findByRole("button", { name: "Connect" }));
344
-
345
- act(() => {
346
- sdk.userConnectStatus({
347
- kind: "connected",
348
- remoteUserId: "remote-user",
349
- remoteDeviceId: "remote-device",
350
- });
351
- });
352
- fireEvent.click(await screen.findByRole("button", { name: "Trust and view contact" }));
353
-
354
- await waitFor(() => {
355
- expect(sdk.Devices.mock.results.at(-1)?.value.get).toHaveBeenCalledWith("remote-device");
356
- expect(sdk.setUserTrustLevel).toHaveBeenCalledWith(
357
- "remote-user",
358
- TrustLevel.Trusted,
359
- expect.anything(),
360
- );
361
- expect(mainContentPath).toHaveBeenCalledWith("contacts/remote-user");
362
- });
363
- });
364
-
365
- it("can view a connected contact without changing trust", async () => {
366
- writeUserConnectPendingIntent({ code: validCode, capturedAt: Date.now() });
367
- render(<UserConnect />);
368
- fireEvent.click(await screen.findByRole("button", { name: "Connect" }));
369
-
370
- act(() => {
371
- sdk.userConnectStatus({
372
- kind: "connected",
373
- remoteUserId: "remote-user",
374
- remoteDeviceId: "remote-device",
375
- });
376
- });
377
- fireEvent.click(await screen.findByRole("button", { name: "View without trusting" }));
378
-
379
- expect(sdk.setUserTrustLevel).not.toHaveBeenCalled();
380
- expect(mainContentPath).toHaveBeenCalledWith("contacts/remote-user");
381
- });
382
- });