@peers-app/peers-ui 0.20.3 → 0.20.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/root-layout/root-layout-host.js +26 -0
- package/dist/root-layout/root-layout-host.test.d.ts +1 -0
- package/dist/root-layout/root-layout-host.test.js +84 -0
- package/dist/screens/contacts/user-connect-invite.d.ts +118 -0
- package/dist/screens/contacts/user-connect-invite.js +264 -0
- package/dist/screens/contacts/user-connect-invite.test.d.ts +1 -0
- package/dist/screens/contacts/user-connect-invite.test.js +215 -0
- package/dist/screens/contacts/user-connect.d.ts +7 -0
- package/dist/screens/contacts/user-connect.js +402 -126
- package/dist/screens/contacts/user-connect.test.d.ts +1 -0
- package/dist/screens/contacts/user-connect.test.js +291 -0
- package/package.json +5 -4
- package/src/root-layout/root-layout-host.test.tsx +106 -0
- package/src/root-layout/root-layout-host.tsx +33 -2
- package/src/screens/contacts/user-connect-invite.test.ts +256 -0
- package/src/screens/contacts/user-connect-invite.ts +332 -0
- package/src/screens/contacts/user-connect.test.tsx +382 -0
- package/src/screens/contacts/user-connect.tsx +735 -310
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
5
|
+
const react_1 = require("@testing-library/react");
|
|
6
|
+
const globals_1 = require("../../globals");
|
|
7
|
+
const user_connect_1 = require("./user-connect");
|
|
8
|
+
const user_connect_invite_1 = require("./user-connect-invite");
|
|
9
|
+
jest.mock("../../globals", () => ({
|
|
10
|
+
mainContentPath: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
jest.mock("../../ui-router/ui-loader", () => ({
|
|
13
|
+
registerInternalPeersUI: jest.fn(),
|
|
14
|
+
}));
|
|
15
|
+
jest.mock("../../hooks", () => {
|
|
16
|
+
const React = jest.requireActual("react");
|
|
17
|
+
return {
|
|
18
|
+
useObservable: (observable) => {
|
|
19
|
+
const [value, setValue] = React.useState(observable());
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
const subscription = observable.subscribe(() => setValue(observable()));
|
|
22
|
+
return () => subscription.dispose();
|
|
23
|
+
}, [observable]);
|
|
24
|
+
return [value, observable];
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
jest.mock("@peers-app/peers-sdk", () => {
|
|
29
|
+
const actual = jest.requireActual("@peers-app/peers-sdk");
|
|
30
|
+
const createObservable = (initial) => {
|
|
31
|
+
let value = initial;
|
|
32
|
+
const listeners = new Set();
|
|
33
|
+
const observable = jest.fn((next) => {
|
|
34
|
+
if (next !== undefined) {
|
|
35
|
+
value = next;
|
|
36
|
+
for (const listener of listeners)
|
|
37
|
+
listener();
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
});
|
|
41
|
+
observable.subscribe = (listener) => {
|
|
42
|
+
listeners.add(listener);
|
|
43
|
+
return { dispose: () => listeners.delete(listener) };
|
|
44
|
+
};
|
|
45
|
+
observable.loadingPromise = Promise.resolve();
|
|
46
|
+
observable.reset = (next) => {
|
|
47
|
+
value = next;
|
|
48
|
+
for (const listener of listeners)
|
|
49
|
+
listener();
|
|
50
|
+
};
|
|
51
|
+
return observable;
|
|
52
|
+
};
|
|
53
|
+
const idle = { kind: "idle" };
|
|
54
|
+
const status = createObservable(idle);
|
|
55
|
+
const offer = createObservable("");
|
|
56
|
+
const answer = createObservable("");
|
|
57
|
+
const remoteUser = {
|
|
58
|
+
userId: "remote-user",
|
|
59
|
+
name: "Ada",
|
|
60
|
+
publicKey: "remote-public-key",
|
|
61
|
+
publicBoxKey: "remote-public-box-key",
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
...actual,
|
|
65
|
+
copyToClipboard: jest.fn(async () => true),
|
|
66
|
+
Devices: jest.fn(() => ({
|
|
67
|
+
get: jest.fn(async () => ({ deviceId: "remote-device", userId: remoteUser.userId })),
|
|
68
|
+
})),
|
|
69
|
+
generateConnectionCode: jest.fn(() => ({
|
|
70
|
+
code: "ABCD1234EFGH",
|
|
71
|
+
alias: "ABCD",
|
|
72
|
+
secret: "1234EFGH",
|
|
73
|
+
})),
|
|
74
|
+
getMe: jest.fn(async () => ({
|
|
75
|
+
userId: "local-user",
|
|
76
|
+
name: "Grace",
|
|
77
|
+
publicKey: "local-public-key",
|
|
78
|
+
publicBoxKey: "local-public-box-key",
|
|
79
|
+
})),
|
|
80
|
+
getUserContext: jest.fn(async () => ({
|
|
81
|
+
userDataContext: {},
|
|
82
|
+
deviceId: () => "local-device",
|
|
83
|
+
})),
|
|
84
|
+
rpcServerCalls: {
|
|
85
|
+
...actual.rpcServerCalls,
|
|
86
|
+
listConnectedDevices: jest.fn(async () => [
|
|
87
|
+
{
|
|
88
|
+
deviceId: "route-device",
|
|
89
|
+
userId: "route-user",
|
|
90
|
+
connectionId: "route-connection",
|
|
91
|
+
trustLevel: 0,
|
|
92
|
+
verified: true,
|
|
93
|
+
},
|
|
94
|
+
]),
|
|
95
|
+
},
|
|
96
|
+
setUserTrustLevel: jest.fn(async () => undefined),
|
|
97
|
+
Users: jest.fn(() => ({
|
|
98
|
+
get: jest.fn(async () => remoteUser),
|
|
99
|
+
})),
|
|
100
|
+
userConnectCodeAnswer: answer,
|
|
101
|
+
userConnectCodeOffer: offer,
|
|
102
|
+
userConnectStatus: status,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
const sdk = jest.requireMock("@peers-app/peers-sdk");
|
|
106
|
+
const validCode = "ABCD1234EFGH";
|
|
107
|
+
function resetMocks() {
|
|
108
|
+
sessionStorage.clear();
|
|
109
|
+
window.history.replaceState(null, "", "/");
|
|
110
|
+
sdk.userConnectStatus.reset({ kind: "idle" });
|
|
111
|
+
sdk.userConnectCodeOffer.reset("");
|
|
112
|
+
sdk.userConnectCodeAnswer.reset("");
|
|
113
|
+
jest.clearAllMocks();
|
|
114
|
+
}
|
|
115
|
+
describe("UserConnect", () => {
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
resetMocks();
|
|
118
|
+
});
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
jest.useRealTimers();
|
|
121
|
+
Reflect.deleteProperty(navigator, "share");
|
|
122
|
+
});
|
|
123
|
+
it("starts with a QR invite and readable fallback code", async () => {
|
|
124
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
125
|
+
expect(await react_1.screen.findByText("Ask the other person to scan this with their camera")).toBeVisible();
|
|
126
|
+
expect(react_1.screen.getByLabelText("Connection QR code")).toBeVisible();
|
|
127
|
+
expect(react_1.screen.getByLabelText("Connection code")).toHaveValue("ABCD-1234-EFGH");
|
|
128
|
+
expect(sdk.userConnectCodeOffer).toHaveBeenCalledWith(validCode);
|
|
129
|
+
});
|
|
130
|
+
it("opens a captured inbound invite in review without auto-submitting", async () => {
|
|
131
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
132
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
133
|
+
expect(await react_1.screen.findByText("You opened a connection invite")).toBeVisible();
|
|
134
|
+
expect(react_1.screen.getByText("ABCD-1234-EFGH")).toBeVisible();
|
|
135
|
+
expect(sdk.userConnectCodeAnswer).not.toHaveBeenCalledWith(validCode);
|
|
136
|
+
});
|
|
137
|
+
it("keeps an unconfirmed invite available across a screen remount", async () => {
|
|
138
|
+
window.history.replaceState(null, "", "/#contacts/connect");
|
|
139
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
140
|
+
const firstRender = (0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
141
|
+
expect(await react_1.screen.findByText("You opened a connection invite")).toBeVisible();
|
|
142
|
+
firstRender.unmount();
|
|
143
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
144
|
+
expect(await react_1.screen.findByText("You opened a connection invite")).toBeVisible();
|
|
145
|
+
expect(react_1.screen.getByText("ABCD-1234-EFGH")).toBeVisible();
|
|
146
|
+
});
|
|
147
|
+
it("clears an unconfirmed invite when navigating away", async () => {
|
|
148
|
+
window.history.replaceState(null, "", "/#contacts/connect");
|
|
149
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
150
|
+
const rendered = (0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
151
|
+
expect(await react_1.screen.findByText("You opened a connection invite")).toBeVisible();
|
|
152
|
+
window.history.replaceState(null, "", "/#contacts");
|
|
153
|
+
rendered.unmount();
|
|
154
|
+
expect((0, user_connect_invite_1.readUserConnectPendingIntent)()).toEqual({ ok: false, reason: "none" });
|
|
155
|
+
});
|
|
156
|
+
it("captures an invite opened while the screen is already mounted", async () => {
|
|
157
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
158
|
+
await react_1.screen.findByText("Ask the other person to scan this with their camera");
|
|
159
|
+
window.history.replaceState(null, "", `/?app#contacts/connect?code=${encodeURIComponent("ABCD-1234-EFGH")}`);
|
|
160
|
+
(0, react_1.fireEvent)(window, new HashChangeEvent("hashchange"));
|
|
161
|
+
expect(await react_1.screen.findByText("You opened a connection invite")).toBeVisible();
|
|
162
|
+
expect(react_1.screen.getByText("ABCD-1234-EFGH")).toBeVisible();
|
|
163
|
+
expect(window.location.hash).toBe("#contacts/connect");
|
|
164
|
+
expect(sdk.userConnectCodeOffer).toHaveBeenCalledWith("");
|
|
165
|
+
expect(sdk.userConnectCodeAnswer).not.toHaveBeenCalledWith(validCode);
|
|
166
|
+
});
|
|
167
|
+
it("copies the complete invite link", async () => {
|
|
168
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
169
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "Copy link" }));
|
|
170
|
+
await (0, react_1.waitFor)(() => {
|
|
171
|
+
expect(sdk.copyToClipboard).toHaveBeenCalledWith((0, user_connect_invite_1.buildUserConnectInviteUrl)(validCode));
|
|
172
|
+
});
|
|
173
|
+
expect((await react_1.screen.findAllByText("Link copied")).length).toBeGreaterThan(0);
|
|
174
|
+
});
|
|
175
|
+
it("shares the complete invite through the Web Share API", async () => {
|
|
176
|
+
const share = jest.fn(async () => undefined);
|
|
177
|
+
Object.defineProperty(navigator, "share", {
|
|
178
|
+
configurable: true,
|
|
179
|
+
value: share,
|
|
180
|
+
});
|
|
181
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
182
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "Share link" }));
|
|
183
|
+
await (0, react_1.waitFor)(() => {
|
|
184
|
+
expect(share).toHaveBeenCalledWith(expect.objectContaining({
|
|
185
|
+
title: "Connect on Peers",
|
|
186
|
+
url: (0, user_connect_invite_1.buildUserConnectInviteUrl)(validCode),
|
|
187
|
+
text: expect.stringContaining("expires in 10 minutes"),
|
|
188
|
+
}));
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
it("stops waiting when the invite expires", async () => {
|
|
192
|
+
jest.useFakeTimers();
|
|
193
|
+
jest.setSystemTime(new Date("2026-07-16T12:00:00Z"));
|
|
194
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
195
|
+
await (0, react_1.act)(async () => {
|
|
196
|
+
jest.advanceTimersByTime(600_000);
|
|
197
|
+
});
|
|
198
|
+
expect(react_1.screen.getByText("Invite expired")).toBeVisible();
|
|
199
|
+
expect(sdk.userConnectCodeOffer).toHaveBeenCalledWith("");
|
|
200
|
+
});
|
|
201
|
+
it("submits an inbound invite only once on a double click", async () => {
|
|
202
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
203
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
204
|
+
const connect = await react_1.screen.findByRole("button", { name: "Connect" });
|
|
205
|
+
react_1.fireEvent.click(connect);
|
|
206
|
+
react_1.fireEvent.click(connect);
|
|
207
|
+
expect(sdk.userConnectCodeAnswer.mock.calls.filter(([code]) => code === validCode)).toHaveLength(1);
|
|
208
|
+
expect((0, user_connect_invite_1.readUserConnectPendingIntent)()).toEqual({ ok: false, reason: "none" });
|
|
209
|
+
});
|
|
210
|
+
it("rechecks invite expiry before confirming review", async () => {
|
|
211
|
+
jest.useFakeTimers();
|
|
212
|
+
jest.setSystemTime(new Date("2026-07-16T12:00:00Z"));
|
|
213
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
214
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
215
|
+
const connect = await react_1.screen.findByRole("button", { name: "Connect" });
|
|
216
|
+
jest.setSystemTime(new Date("2026-07-16T12:10:00Z"));
|
|
217
|
+
react_1.fireEvent.click(connect);
|
|
218
|
+
expect(react_1.screen.getByText("Invite expired")).toBeVisible();
|
|
219
|
+
expect(sdk.userConnectCodeAnswer).not.toHaveBeenCalledWith(validCode);
|
|
220
|
+
});
|
|
221
|
+
it("does not treat the initial idle status as a canceled connection", async () => {
|
|
222
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
223
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
224
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "Connect" }));
|
|
225
|
+
expect(react_1.screen.getByText("Connecting…")).toBeVisible();
|
|
226
|
+
expect(react_1.screen.queryByText("Connection canceled")).not.toBeInTheDocument();
|
|
227
|
+
});
|
|
228
|
+
it("leaves connecting when the answer expires back to idle", async () => {
|
|
229
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
230
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
231
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "Connect" }));
|
|
232
|
+
(0, react_1.act)(() => {
|
|
233
|
+
sdk.userConnectCodeAnswer("");
|
|
234
|
+
sdk.userConnectStatus({ kind: "idle" });
|
|
235
|
+
});
|
|
236
|
+
expect(await react_1.screen.findByText("Invite expired")).toBeVisible();
|
|
237
|
+
});
|
|
238
|
+
it("reports cancellation when an active protocol resets to idle", async () => {
|
|
239
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
240
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
241
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "Connect" }));
|
|
242
|
+
(0, react_1.act)(() => {
|
|
243
|
+
sdk.userConnectStatus({ kind: "connecting", attempt: 1 });
|
|
244
|
+
});
|
|
245
|
+
(0, react_1.act)(() => {
|
|
246
|
+
sdk.userConnectStatus({ kind: "idle" });
|
|
247
|
+
});
|
|
248
|
+
expect(await react_1.screen.findByText("Connection canceled")).toBeVisible();
|
|
249
|
+
});
|
|
250
|
+
it("renders actionable structured errors", async () => {
|
|
251
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
252
|
+
(0, react_1.act)(() => {
|
|
253
|
+
sdk.userConnectStatus({ kind: "error", code: "no-route" });
|
|
254
|
+
});
|
|
255
|
+
expect(await react_1.screen.findByText("This device isn’t connected yet")).toBeVisible();
|
|
256
|
+
expect(react_1.screen.getByText("Connect this device to the internet or another Peers device, then try again.")).toBeVisible();
|
|
257
|
+
});
|
|
258
|
+
it("trusts only when the primary success action is selected", async () => {
|
|
259
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
260
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
261
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "Connect" }));
|
|
262
|
+
(0, react_1.act)(() => {
|
|
263
|
+
sdk.userConnectStatus({
|
|
264
|
+
kind: "connected",
|
|
265
|
+
remoteUserId: "remote-user",
|
|
266
|
+
remoteDeviceId: "remote-device",
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "Trust and view contact" }));
|
|
270
|
+
await (0, react_1.waitFor)(() => {
|
|
271
|
+
expect(sdk.Devices.mock.results.at(-1)?.value.get).toHaveBeenCalledWith("remote-device");
|
|
272
|
+
expect(sdk.setUserTrustLevel).toHaveBeenCalledWith("remote-user", peers_sdk_1.TrustLevel.Trusted, expect.anything());
|
|
273
|
+
expect(globals_1.mainContentPath).toHaveBeenCalledWith("contacts/remote-user");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
it("can view a connected contact without changing trust", async () => {
|
|
277
|
+
(0, user_connect_invite_1.writeUserConnectPendingIntent)({ code: validCode, capturedAt: Date.now() });
|
|
278
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(user_connect_1.UserConnect, {}));
|
|
279
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "Connect" }));
|
|
280
|
+
(0, react_1.act)(() => {
|
|
281
|
+
sdk.userConnectStatus({
|
|
282
|
+
kind: "connected",
|
|
283
|
+
remoteUserId: "remote-user",
|
|
284
|
+
remoteDeviceId: "remote-device",
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
react_1.fireEvent.click(await react_1.screen.findByRole("button", { name: "View without trusting" }));
|
|
288
|
+
expect(sdk.setUserTrustLevel).not.toHaveBeenCalled();
|
|
289
|
+
expect(globals_1.mainContentPath).toHaveBeenCalledWith("contacts/remote-user");
|
|
290
|
+
});
|
|
291
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.5",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -28,18 +28,18 @@
|
|
|
28
28
|
"lint:fix": "biome check --write ."
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@peers-app/peers-sdk": "^0.20.
|
|
31
|
+
"@peers-app/peers-sdk": "^0.20.5",
|
|
32
32
|
"bootstrap": "^5.3.3",
|
|
33
33
|
"react": "^18.0.0",
|
|
34
34
|
"react-dom": "^18.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@biomejs/biome": "2.3.11",
|
|
38
37
|
"@babel/core": "^7.24.5",
|
|
39
38
|
"@babel/preset-env": "^7.24.5",
|
|
40
39
|
"@babel/preset-react": "^7.24.1",
|
|
41
40
|
"@babel/preset-typescript": "^7.27.1",
|
|
42
|
-
"@
|
|
41
|
+
"@biomejs/biome": "2.3.11",
|
|
42
|
+
"@peers-app/peers-sdk": "0.20.5",
|
|
43
43
|
"@testing-library/dom": "^10.4.0",
|
|
44
44
|
"@testing-library/jest-dom": "^6.6.3",
|
|
45
45
|
"@testing-library/react": "^16.3.0",
|
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
"moment-timezone": "^0.5.46",
|
|
99
99
|
"react-infinite-scroll-component": "^6.1.0",
|
|
100
100
|
"react-markdown": "^9.0.1",
|
|
101
|
+
"react-qr-code": "^2.2.0",
|
|
101
102
|
"react-tiny-popover": "^8.0.4",
|
|
102
103
|
"remark-gfm": "^4.0.0",
|
|
103
104
|
"socket.io": "^4.7.5",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
2
|
+
import { mainContentPath } from "../globals";
|
|
3
|
+
import { readUserConnectPendingIntent } from "../screens/contacts/user-connect-invite";
|
|
4
|
+
import { RootLayoutHost } from "./root-layout-host";
|
|
5
|
+
|
|
6
|
+
jest.mock("@peers-app/peers-sdk", () => {
|
|
7
|
+
const actual = jest.requireActual<typeof import("@peers-app/peers-sdk")>("@peers-app/peers-sdk");
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
hasShownWelcomeModal: Object.assign(
|
|
11
|
+
jest.fn(() => true),
|
|
12
|
+
{
|
|
13
|
+
loadingPromise: Promise.resolve(),
|
|
14
|
+
},
|
|
15
|
+
),
|
|
16
|
+
rpcServerCalls: {
|
|
17
|
+
...actual.rpcServerCalls,
|
|
18
|
+
getUserId: jest.fn(async () => "local-user"),
|
|
19
|
+
},
|
|
20
|
+
setDefaultClientUserContext: jest.fn(),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
jest.mock("../command-palette/command-palette-ui", () => ({
|
|
25
|
+
CommandPaletteOverlay: () => null,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
jest.mock("../globals", () => ({
|
|
29
|
+
loadGlobals: jest.fn(async () => true),
|
|
30
|
+
mainContentPath: jest.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
jest.mock("../hooks", () => ({
|
|
34
|
+
useObservable: (observable: () => unknown) => [observable(), observable],
|
|
35
|
+
usePromise: (promiseOrFactory: unknown) => {
|
|
36
|
+
if (
|
|
37
|
+
typeof promiseOrFactory === "function" &&
|
|
38
|
+
Function.prototype.toString.call(promiseOrFactory).includes("getUserId")
|
|
39
|
+
) {
|
|
40
|
+
return "local-user";
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
jest.mock("../screens/settings/color-mode-dropdown", () => ({
|
|
47
|
+
colorMode: jest.fn(() => "light"),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
jest.mock("../screens/setup-user", () => ({
|
|
51
|
+
SetupUser: () => null,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
jest.mock("../screens/welcome-modal", () => ({
|
|
55
|
+
WelcomeModal: () => null,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
jest.mock("../tabs-layout/tabs-state", () => ({
|
|
59
|
+
activeTabId: { loadingPromise: Promise.resolve() },
|
|
60
|
+
activeTabs: { loadingPromise: Promise.resolve() },
|
|
61
|
+
recentlyUsedApps: { loadingPromise: Promise.resolve() },
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
jest.mock("../ui-router/routes-loader", () => ({
|
|
65
|
+
loadAllRoutes: jest.fn(async () => undefined),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
jest.mock("./global-overlays", () => ({
|
|
69
|
+
globalOverlays: jest.fn(() => []),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
jest.mock("./root-layout-registry", () => ({
|
|
73
|
+
DEFAULT_ROOT_LAYOUT_ID: "default",
|
|
74
|
+
getRootLayout: jest.fn(() => ({ Component: () => null })),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
jest.mock("./root-layout-state", () => ({
|
|
78
|
+
selectedRootLayoutId: jest.fn(() => undefined),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
describe("RootLayoutHost", () => {
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
sessionStorage.clear();
|
|
84
|
+
window.history.replaceState(null, "", "/");
|
|
85
|
+
jest.clearAllMocks();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("captures and sanitizes invite links opened after startup", () => {
|
|
89
|
+
render(<RootLayoutHost />);
|
|
90
|
+
window.history.replaceState(
|
|
91
|
+
null,
|
|
92
|
+
"",
|
|
93
|
+
`/?app#contacts/connect?code=${encodeURIComponent("ABCD-1234-EFGH")}`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
fireEvent(window, new HashChangeEvent("hashchange"));
|
|
97
|
+
|
|
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="));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -17,10 +17,14 @@ import {
|
|
|
17
17
|
rpcServerCalls,
|
|
18
18
|
setDefaultClientUserContext,
|
|
19
19
|
} from "@peers-app/peers-sdk";
|
|
20
|
-
import { useEffect, useState } from "react";
|
|
20
|
+
import { useEffect, useRef, useState } from "react";
|
|
21
21
|
import { CommandPaletteOverlay } from "../command-palette/command-palette-ui";
|
|
22
|
-
import { loadGlobals } from "../globals";
|
|
22
|
+
import { loadGlobals, mainContentPath } from "../globals";
|
|
23
23
|
import { useObservable, usePromise } from "../hooks";
|
|
24
|
+
import {
|
|
25
|
+
captureUserConnectInviteFromLocation,
|
|
26
|
+
readUserConnectPendingIntent,
|
|
27
|
+
} from "../screens/contacts/user-connect-invite";
|
|
24
28
|
import { colorMode } from "../screens/settings/color-mode-dropdown";
|
|
25
29
|
import { SetupUser } from "../screens/setup-user";
|
|
26
30
|
import { WelcomeModal } from "../screens/welcome-modal";
|
|
@@ -36,10 +40,33 @@ import { selectedRootLayoutId } from "./root-layout-state";
|
|
|
36
40
|
* order downstream is stable across the user/no-user branches.
|
|
37
41
|
*/
|
|
38
42
|
export function RootLayoutHost() {
|
|
43
|
+
useState(() => {
|
|
44
|
+
captureUserConnectInviteFromLocation();
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const userIdRef = useRef<string | null | undefined>(undefined);
|
|
39
49
|
const userId = usePromise(async () => {
|
|
40
50
|
const _userId = await rpcServerCalls.getUserId();
|
|
41
51
|
return _userId || (null as string | null);
|
|
42
52
|
});
|
|
53
|
+
userIdRef.current = userId;
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const captureInviteFromHashChange = () => {
|
|
57
|
+
const previousHash = window.location.hash;
|
|
58
|
+
captureUserConnectInviteFromLocation();
|
|
59
|
+
if (window.location.hash === previousHash) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Signed-out captures stay in sessionStorage until setup finishes.
|
|
63
|
+
if (userIdRef.current) {
|
|
64
|
+
mainContentPath("contacts/connect");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
window.addEventListener("hashchange", captureInviteFromHashChange);
|
|
68
|
+
return () => window.removeEventListener("hashchange", captureInviteFromHashChange);
|
|
69
|
+
}, []);
|
|
43
70
|
|
|
44
71
|
if (userId === undefined) {
|
|
45
72
|
return;
|
|
@@ -75,6 +102,10 @@ function RootLayoutBootstrap(props: { userId: string }) {
|
|
|
75
102
|
recentlyUsedApps.loadingPromise,
|
|
76
103
|
hasShownWelcomeModal.loadingPromise,
|
|
77
104
|
]);
|
|
105
|
+
const pendingInvite = readUserConnectPendingIntent();
|
|
106
|
+
if (pendingInvite.ok || pendingInvite.reason !== "none") {
|
|
107
|
+
mainContentPath("contacts/connect");
|
|
108
|
+
}
|
|
78
109
|
return true;
|
|
79
110
|
});
|
|
80
111
|
|