@peers-app/peers-ui 0.25.2 → 0.25.4

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 (180) hide show
  1. package/README.md +2 -1
  2. package/dist/components/markdown-editor/mention-node.d.ts +4 -0
  3. package/dist/components/markdown-editor/mention-node.js +4 -1
  4. package/dist/components/responsive-section-nav.d.ts +34 -0
  5. package/dist/components/responsive-section-nav.js +19 -0
  6. package/dist/components/responsive-section-nav.test.d.ts +1 -0
  7. package/dist/components/responsive-section-nav.test.js +45 -0
  8. package/dist/components/router.d.ts +2 -3
  9. package/dist/components/router.js +6 -9
  10. package/dist/globals-hash.test.d.ts +1 -0
  11. package/dist/globals-hash.test.js +16 -0
  12. package/dist/globals.d.ts +19 -0
  13. package/dist/globals.js +51 -12
  14. package/dist/hooks/use-host-contract.d.ts +18 -0
  15. package/dist/hooks/use-host-contract.js +53 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/peers-services-registration.d.ts +1 -5
  19. package/dist/peers-services-registration.js +6 -16
  20. package/dist/root-layout/root-layout-host.js +7 -4
  21. package/dist/root-layout/root-layout-host.test.js +3 -6
  22. package/dist/screens/account/account-screen.d.ts +1 -0
  23. package/dist/screens/account/account-screen.js +29 -53
  24. package/dist/screens/account/account-screen.test.d.ts +1 -0
  25. package/dist/screens/account/account-screen.test.js +47 -0
  26. package/dist/screens/contacts/contact-details.js +2 -23
  27. package/dist/screens/contacts/contact-list.d.ts +24 -1
  28. package/dist/screens/contacts/contact-list.js +128 -143
  29. package/dist/screens/contacts/contact-list.test.d.ts +1 -0
  30. package/dist/screens/contacts/contact-list.test.js +48 -0
  31. package/dist/screens/contacts/index.d.ts +0 -2
  32. package/dist/screens/contacts/index.js +0 -3
  33. package/dist/screens/device-pairing/device-pairing.js +17 -16
  34. package/dist/screens/device-pairing/device-pairing.test.js +4 -9
  35. package/dist/screens/device-pairing/identity-ipc.d.ts +11 -0
  36. package/dist/screens/device-pairing/identity-ipc.js +20 -0
  37. package/dist/screens/device-pairing/use-device-pairing-state.d.ts +2 -2
  38. package/dist/screens/device-pairing/use-device-pairing-state.js +7 -25
  39. package/dist/screens/devices/devices-list.d.ts +18 -0
  40. package/dist/screens/devices/devices-list.js +173 -0
  41. package/dist/screens/devices/devices-list.test.d.ts +1 -0
  42. package/dist/screens/devices/devices-list.test.js +53 -0
  43. package/dist/screens/devices/index.d.ts +1 -0
  44. package/dist/screens/devices/index.js +5 -0
  45. package/dist/screens/groups/group-details.d.ts +1 -0
  46. package/dist/screens/groups/group-details.js +4 -63
  47. package/dist/screens/groups/group-invite-panel.js +2 -2
  48. package/dist/screens/groups/group-list.d.ts +18 -1
  49. package/dist/screens/groups/group-list.js +107 -136
  50. package/dist/screens/groups/group-list.test.d.ts +1 -0
  51. package/dist/screens/groups/group-list.test.js +38 -0
  52. package/dist/screens/groups/group-members.d.ts +1 -0
  53. package/dist/screens/groups/group-members.js +2 -1
  54. package/dist/screens/groups/index.d.ts +0 -3
  55. package/dist/screens/groups/index.js +0 -4
  56. package/dist/screens/identity/identity-routes.d.ts +107 -0
  57. package/dist/screens/identity/identity-routes.js +245 -0
  58. package/dist/screens/identity/identity-routes.test.d.ts +1 -0
  59. package/dist/screens/identity/identity-routes.test.js +51 -0
  60. package/dist/screens/identity/identity-screen.d.ts +9 -0
  61. package/dist/screens/identity/identity-screen.js +143 -0
  62. package/dist/screens/identity/identity-screen.test.d.ts +1 -0
  63. package/dist/screens/identity/identity-screen.test.js +103 -0
  64. package/dist/screens/identity/index.d.ts +2 -0
  65. package/dist/screens/identity/index.js +6 -0
  66. package/dist/screens/identity/invite-contact-panel.d.ts +4 -0
  67. package/dist/screens/identity/invite-contact-panel.js +30 -0
  68. package/dist/screens/identity/profile-invite.d.ts +7 -0
  69. package/dist/screens/identity/profile-invite.js +62 -0
  70. package/dist/screens/invites/accept-invite.js +44 -2
  71. package/dist/screens/invites/accept-invite.test.d.ts +1 -0
  72. package/dist/screens/invites/accept-invite.test.js +103 -0
  73. package/dist/screens/invites/activity-view.d.ts +24 -0
  74. package/dist/screens/invites/activity-view.js +64 -0
  75. package/dist/screens/invites/activity-view.test.d.ts +1 -0
  76. package/dist/screens/invites/activity-view.test.js +108 -0
  77. package/dist/screens/invites/index.d.ts +1 -2
  78. package/dist/screens/invites/index.js +1 -3
  79. package/dist/screens/invites/invite-capture.d.ts +0 -2
  80. package/dist/screens/invites/invite-capture.js +1 -3
  81. package/dist/screens/invites/invite-classify.test.d.ts +1 -0
  82. package/dist/screens/invites/invite-classify.test.js +115 -0
  83. package/dist/screens/invites/invites-ipc.d.ts +1 -1
  84. package/dist/screens/invites/invites-ipc.js +5 -45
  85. package/dist/screens/invites/pending-invites-list.d.ts +35 -16
  86. package/dist/screens/invites/pending-invites-list.js +75 -30
  87. package/dist/screens/network-viewer/network-viewer-ipc.js +4 -59
  88. package/dist/screens/search/global-search.d.ts +1 -0
  89. package/dist/screens/search/global-search.js +2 -1
  90. package/dist/screens/settings/settings-page.js +25 -58
  91. package/dist/screens/setup-user.js +5 -5
  92. package/dist/screens/tools/tool-access-ipc.d.ts +2 -0
  93. package/dist/screens/tools/tool-access-ipc.js +8 -55
  94. package/dist/screens/welcome-modal.js +3 -2
  95. package/dist/system-apps/identity.app.d.ts +3 -0
  96. package/dist/system-apps/identity.app.js +10 -0
  97. package/dist/system-apps/index.d.ts +1 -4
  98. package/dist/system-apps/index.js +5 -17
  99. package/dist/tabs-layout/network-status-indicator.js +10 -3
  100. package/dist/tabs-layout/network-status-indicator.test.js +15 -0
  101. package/dist/tabs-layout/tabs-layout.js +3 -1
  102. package/dist/tabs-layout/tabs-state.d.ts +79 -1
  103. package/dist/tabs-layout/tabs-state.js +256 -79
  104. package/dist/tabs-layout/tabs-state.test.d.ts +1 -0
  105. package/dist/tabs-layout/tabs-state.test.js +110 -0
  106. package/package.json +3 -3
  107. package/src/components/markdown-editor/mention-node.ts +5 -1
  108. package/src/components/responsive-section-nav.test.tsx +88 -0
  109. package/src/components/responsive-section-nav.tsx +92 -0
  110. package/src/components/router.tsx +6 -9
  111. package/src/globals-hash.test.ts +19 -0
  112. package/src/globals.tsx +53 -13
  113. package/src/hooks/use-host-contract.ts +75 -0
  114. package/src/index.tsx +1 -0
  115. package/src/peers-services-registration.ts +6 -17
  116. package/src/root-layout/root-layout-host.test.tsx +4 -7
  117. package/src/root-layout/root-layout-host.tsx +9 -11
  118. package/src/screens/account/account-screen.test.tsx +61 -0
  119. package/src/screens/account/account-screen.tsx +116 -95
  120. package/src/screens/contacts/contact-details.tsx +4 -26
  121. package/src/screens/contacts/contact-list.test.tsx +59 -0
  122. package/src/screens/contacts/contact-list.tsx +246 -260
  123. package/src/screens/contacts/index.ts +0 -4
  124. package/src/screens/device-pairing/device-pairing.test.tsx +7 -9
  125. package/src/screens/device-pairing/device-pairing.tsx +19 -23
  126. package/src/screens/device-pairing/identity-ipc.ts +25 -0
  127. package/src/screens/device-pairing/use-device-pairing-state.ts +10 -33
  128. package/src/screens/devices/devices-list.test.tsx +68 -0
  129. package/src/screens/devices/devices-list.tsx +361 -0
  130. package/src/screens/devices/index.ts +1 -0
  131. package/src/screens/groups/group-details.tsx +8 -142
  132. package/src/screens/groups/group-invite-panel.tsx +2 -7
  133. package/src/screens/groups/group-list.test.tsx +44 -0
  134. package/src/screens/groups/group-list.tsx +227 -288
  135. package/src/screens/groups/group-members.tsx +3 -2
  136. package/src/screens/groups/index.ts +0 -5
  137. package/src/screens/identity/identity-routes.test.ts +70 -0
  138. package/src/screens/identity/identity-routes.ts +301 -0
  139. package/src/screens/identity/identity-screen.test.tsx +138 -0
  140. package/src/screens/identity/identity-screen.tsx +282 -0
  141. package/src/screens/identity/index.ts +2 -0
  142. package/src/screens/identity/invite-contact-panel.tsx +45 -0
  143. package/src/screens/identity/profile-invite.tsx +93 -0
  144. package/src/screens/invites/accept-invite.test.tsx +115 -0
  145. package/src/screens/invites/accept-invite.tsx +50 -3
  146. package/src/screens/invites/activity-view.test.tsx +127 -0
  147. package/src/screens/invites/activity-view.tsx +162 -0
  148. package/src/screens/invites/index.ts +1 -4
  149. package/src/screens/invites/invite-capture.ts +0 -3
  150. package/src/screens/invites/invite-classify.test.ts +129 -0
  151. package/src/screens/invites/invites-ipc.ts +6 -55
  152. package/src/screens/invites/pending-invites-list.tsx +94 -62
  153. package/src/screens/network-viewer/network-viewer-ipc.ts +8 -82
  154. package/src/screens/search/global-search.tsx +2 -1
  155. package/src/screens/settings/settings-page.tsx +53 -126
  156. package/src/screens/setup-user.tsx +5 -6
  157. package/src/screens/tools/tool-access-ipc.ts +13 -74
  158. package/src/screens/welcome-modal.tsx +3 -3
  159. package/src/system-apps/identity.app.ts +9 -0
  160. package/src/system-apps/index.ts +3 -12
  161. package/src/tabs-layout/network-status-indicator.test.tsx +22 -0
  162. package/src/tabs-layout/network-status-indicator.tsx +31 -0
  163. package/src/tabs-layout/tabs-layout.tsx +7 -1
  164. package/src/tabs-layout/tabs-state.test.ts +152 -0
  165. package/src/tabs-layout/tabs-state.ts +300 -83
  166. package/dist/screens/invites/invites-screen.d.ts +0 -9
  167. package/dist/screens/invites/invites-screen.js +0 -36
  168. package/dist/system-apps/account.app.d.ts +0 -2
  169. package/dist/system-apps/account.app.js +0 -8
  170. package/dist/system-apps/contacts.app.d.ts +0 -2
  171. package/dist/system-apps/contacts.app.js +0 -9
  172. package/dist/system-apps/groups.app.d.ts +0 -2
  173. package/dist/system-apps/groups.app.js +0 -9
  174. package/dist/system-apps/invites.app.d.ts +0 -2
  175. package/dist/system-apps/invites.app.js +0 -9
  176. package/src/screens/invites/invites-screen.tsx +0 -78
  177. package/src/system-apps/account.app.ts +0 -7
  178. package/src/system-apps/contacts.app.ts +0 -8
  179. package/src/system-apps/groups.app.ts +0 -8
  180. package/src/system-apps/invites.app.ts +0 -8
@@ -1,12 +1,8 @@
1
- import {
2
- copyToClipboard,
3
- type DevicePairingState,
4
- parsePairingCode,
5
- rpcServerCalls,
6
- } from "@peers-app/peers-sdk";
1
+ import { copyToClipboard, type DevicePairingState, parsePairingCode } from "@peers-app/peers-sdk";
7
2
  import { useEffect, useRef, useState } from "react";
8
3
  import QRCode from "react-qr-code";
9
4
  import { canScanQr, QrScanner } from "../../components/qr-scanner";
5
+ import { identityTools } from "./identity-ipc";
10
6
  import { useDevicePairingState } from "./use-device-pairing-state";
11
7
 
12
8
  function isPairingCode(text: string): boolean {
@@ -165,8 +161,8 @@ function usePairingSessionLifecycle(state: DevicePairingState) {
165
161
  mounted.current = false;
166
162
  const activeSessionId = sessionId.current;
167
163
  if (activeSessionId && !["idle", ...TERMINAL_PHASES].includes(phase.current)) {
168
- void rpcServerCalls
169
- .devicePairingCancel({ sessionId: activeSessionId })
164
+ void identityTools()
165
+ .then((tools) => tools.cancel({ sessionId: activeSessionId }))
170
166
  .catch(() => undefined);
171
167
  }
172
168
  };
@@ -189,10 +185,11 @@ function useCeremonyActions(
189
185
  }
190
186
  setApproving(true);
191
187
  try {
188
+ const tools = await identityTools();
192
189
  if (action === "approve") {
193
- await rpcServerCalls.devicePairingApprove({ sessionId });
190
+ await tools.approve({ sessionId });
194
191
  } else {
195
- await rpcServerCalls.devicePairingReject({ sessionId });
192
+ await tools.reject({ sessionId });
196
193
  }
197
194
  } catch {
198
195
  setLocalError("Could not update the pairing ceremony.");
@@ -205,7 +202,9 @@ function useCeremonyActions(
205
202
  const sessionId = lifecycle.sessionId.current;
206
203
  lifecycle.phase.current = "idle";
207
204
  if (sessionId) {
208
- await rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
205
+ await identityTools()
206
+ .then((tools) => tools.cancel({ sessionId }))
207
+ .catch(() => undefined);
209
208
  }
210
209
  onLeave();
211
210
  };
@@ -234,13 +233,12 @@ export function SourceDevicePairing(props: { onClose: () => void }) {
234
233
  setLocalError("");
235
234
  setStarting(true);
236
235
  try {
237
- const result = await rpcServerCalls.devicePairingStartSource({ code: rawCode.trim() });
236
+ const tools = await identityTools();
237
+ const result = await tools.startSource({ code: rawCode.trim() });
238
238
  lifecycle.sessionId.current = result.sessionId;
239
239
  lifecycle.phase.current = result.phase;
240
240
  if (!lifecycle.mounted.current && result.sessionId) {
241
- await rpcServerCalls
242
- .devicePairingCancel({ sessionId: result.sessionId })
243
- .catch(() => undefined);
241
+ await tools.cancel({ sessionId: result.sessionId }).catch(() => undefined);
244
242
  }
245
243
  } catch {
246
244
  setLocalError("That code was not recognized or the new device is no longer waiting.");
@@ -373,8 +371,8 @@ function ShowingCode(props: { state: DevicePairingState; onCancel: () => void })
373
371
  <div className="text-center" style={{ maxWidth: 480, margin: "0 auto" }}>
374
372
  <h3>Sign in from another device</h3>
375
373
  <p className="text-muted">
376
- On a device already signed in to Peers, open Settings → <strong>Add another device</strong>{" "}
377
- and type this code or scan it.
374
+ On a device already signed in to Peers, open{" "}
375
+ <strong>Identity → Devices → Add a device</strong> and type this code or scan it.
378
376
  </p>
379
377
  {props.state.notice ? <div className="alert alert-warning">{props.state.notice}</div> : null}
380
378
  <output
@@ -418,15 +416,13 @@ export function DestinationDevicePairing(props: { onBack: () => void }) {
418
416
  return;
419
417
  }
420
418
  started.current = true;
421
- void rpcServerCalls
422
- .devicePairingStartDestination({ deviceType: detectDeviceType() })
423
- .then(async (result) => {
419
+ void identityTools()
420
+ .then(async (tools) => {
421
+ const result = await tools.startDestination({ deviceType: detectDeviceType() });
424
422
  lifecycle.sessionId.current = result.sessionId;
425
423
  lifecycle.phase.current = result.phase;
426
424
  if (!lifecycle.mounted.current && result.sessionId) {
427
- await rpcServerCalls
428
- .devicePairingCancel({ sessionId: result.sessionId })
429
- .catch(() => undefined);
425
+ await tools.cancel({ sessionId: result.sessionId }).catch(() => undefined);
430
426
  }
431
427
  })
432
428
  .catch(() => setLocalError("Could not reach the pairing service. Check your connection."));
@@ -0,0 +1,25 @@
1
+ import {
2
+ getIdentityContractDefinition,
3
+ hostContractConsumer,
4
+ type IHostContractConsumer,
5
+ type IIdentityConsumer,
6
+ } from "@peers-app/peers-sdk";
7
+
8
+ /**
9
+ * Cached local-host consumer for System Identity.
10
+ *
11
+ * Pairing and session tools resolve before sign-in, so this handle passes no data context.
12
+ */
13
+ export const identityContract: IHostContractConsumer<IIdentityConsumer> = hostContractConsumer(
14
+ getIdentityContractDefinition(),
15
+ );
16
+
17
+ /** Return the typed local-host consumer for System Identity. */
18
+ export async function getIdentityConsumer(): Promise<IIdentityConsumer> {
19
+ return identityContract.get();
20
+ }
21
+
22
+ /** Shorthand for `(await getIdentityConsumer()).tools`. */
23
+ export async function identityTools(): Promise<IIdentityConsumer["tools"]> {
24
+ return (await getIdentityConsumer()).tools;
25
+ }
@@ -1,38 +1,15 @@
1
- import {
2
- DEVICE_PAIRING_STATE_EVENT,
3
- type DevicePairingState,
4
- devicePairingStateSchema,
5
- rpcServerCalls,
6
- subscribe,
7
- } from "@peers-app/peers-sdk";
8
- import { useEffect, useState } from "react";
1
+ import type { DevicePairingState } from "@peers-app/peers-sdk";
2
+ import { useObservable } from "../../hooks";
3
+ import { useHostContract } from "../../hooks/use-host-contract";
4
+ import { identityContract } from "./identity-ipc";
9
5
 
10
6
  const IDLE_PAIRING_STATE: DevicePairingState = { phase: "idle" };
11
7
 
12
- /** Subscribes to renderer-safe host pairing state and loads its current snapshot. */
8
+ /** Subscribe to the host pairing ceremony. Idle until the identity consumer is ready. */
13
9
  export function useDevicePairingState(): DevicePairingState {
14
- const [state, setState] = useState<DevicePairingState>(IDLE_PAIRING_STATE);
15
-
16
- useEffect(() => {
17
- let active = true;
18
- const subscription = subscribe<DevicePairingState>(DEVICE_PAIRING_STATE_EVENT, (event) => {
19
- if (active) {
20
- setState(devicePairingStateSchema.parse(event.data));
21
- }
22
- });
23
- void rpcServerCalls
24
- .devicePairingGetState()
25
- .then((value) => {
26
- if (active) {
27
- setState(devicePairingStateSchema.parse(value));
28
- }
29
- })
30
- .catch(() => undefined);
31
- return () => {
32
- active = false;
33
- subscription.unsubscribe();
34
- };
35
- }, []);
36
-
37
- return state;
10
+ const { consumer } = useHostContract(identityContract);
11
+ const [state] = useObservable<DevicePairingState>(
12
+ consumer?.observables.state ?? IDLE_PAIRING_STATE,
13
+ );
14
+ return state ?? IDLE_PAIRING_STATE;
38
15
  }
@@ -0,0 +1,68 @@
1
+ import { type IDevice, TrustLevel } from "@peers-app/peers-sdk";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { DeviceRow, devicesForUser } from "./devices-list";
4
+
5
+ jest.mock("../../globals", () => ({
6
+ mainContentPath: jest.fn(),
7
+ }));
8
+
9
+ jest.mock("../../hooks", () => ({
10
+ useObservable: jest.fn(),
11
+ useObservableState: jest.fn(),
12
+ usePromise: jest.fn(),
13
+ }));
14
+
15
+ jest.mock("../device-pairing", () => ({
16
+ SourceDevicePairing: () => null,
17
+ }));
18
+
19
+ jest.mock("../invites/activity-view", () => ({
20
+ PendingWorkLink: () => null,
21
+ }));
22
+
23
+ jest.mock("../network-viewer/network-viewer-ipc", () => ({
24
+ getNetworkControlConsumer: jest.fn(),
25
+ getNetworkDiagnosticsConsumer: jest.fn(),
26
+ }));
27
+
28
+ function device(deviceId: string, userId: string, name: string): IDevice {
29
+ return {
30
+ deviceId,
31
+ userId,
32
+ name,
33
+ firstSeen: new Date("2026-01-01T00:00:00Z"),
34
+ lastSeen: new Date(),
35
+ trustLevel: TrustLevel.Trusted,
36
+ };
37
+ }
38
+
39
+ describe("Devices identity list", () => {
40
+ it("filters out devices owned by other users", () => {
41
+ const mine = device("abcdefghijklmnopqrstuvwxy", "mine000000000000000000001", "Laptop");
42
+ const theirs = device("zyxwvutsrqponmlkjihgfedcb", "theirs000000000000000001", "Other");
43
+
44
+ expect(devicesForUser([mine, theirs], mine.userId)).toEqual([mine]);
45
+ });
46
+
47
+ it("shows the current-device and connection context without a destructive row action", () => {
48
+ const mine = device("abcdefghijklmnopqrstuvwxy", "mine000000000000000000001", "Laptop");
49
+ render(
50
+ <DeviceRow
51
+ device={mine}
52
+ isThisDevice
53
+ connection={
54
+ {
55
+ deviceId: mine.deviceId,
56
+ connectionState: "connected",
57
+ pathType: "direct",
58
+ } as never
59
+ }
60
+ />,
61
+ );
62
+
63
+ expect(screen.getByText("Laptop")).toBeInTheDocument();
64
+ expect(screen.getByText("This device")).toBeInTheDocument();
65
+ expect(screen.getByText("Online")).toBeInTheDocument();
66
+ expect(screen.queryByRole("button", { name: /Forget/i })).not.toBeInTheDocument();
67
+ });
68
+ });
@@ -0,0 +1,361 @@
1
+ import {
2
+ Devices,
3
+ DISPLAY_NAME_MAX_LENGTH,
4
+ getUserContext,
5
+ type IDevice,
6
+ type INetworkConnectionInfo,
7
+ TrustLevel,
8
+ thisDeviceId,
9
+ } from "@peers-app/peers-sdk";
10
+ import { useEffect, useState } from "react";
11
+ import { Input } from "../../components/input";
12
+ import { LoadingIndicator } from "../../components/loading-indicator";
13
+ import { mainContentPath } from "../../globals";
14
+ import { useObservable, useObservableState, usePromise } from "../../hooks";
15
+ import { SourceDevicePairing } from "../device-pairing";
16
+ import { IDENTITY_DEVICES_PATH, IDENTITY_PAIR_PATH } from "../identity/identity-routes";
17
+ import { PendingWorkLink } from "../invites/activity-view";
18
+ import {
19
+ getNetworkControlConsumer,
20
+ getNetworkDiagnosticsConsumer,
21
+ } from "../network-viewer/network-viewer-ipc";
22
+
23
+ function trustLabel(level: TrustLevel): string {
24
+ return TrustLevel[level] ?? String(level);
25
+ }
26
+
27
+ function connectionLabel(connection: INetworkConnectionInfo | undefined): string {
28
+ if (!connection) {
29
+ return "Offline";
30
+ }
31
+ const path =
32
+ connection.pathType === "direct"
33
+ ? "direct"
34
+ : connection.pathType === "indirect"
35
+ ? "indirect"
36
+ : "unknown path";
37
+ return `${connection.connectionState} · ${path}`;
38
+ }
39
+
40
+ /** Compact, fully clickable row for one of the signed-in user's devices. */
41
+ export function DeviceRow(props: {
42
+ device: IDevice;
43
+ connection?: INetworkConnectionInfo;
44
+ isThisDevice: boolean;
45
+ }) {
46
+ const connected = props.connection !== undefined;
47
+ return (
48
+ <a
49
+ href={`#${IDENTITY_DEVICES_PATH}/${props.device.deviceId}`}
50
+ className="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3"
51
+ >
52
+ <span
53
+ className="rounded-3 bg-body-secondary d-inline-flex align-items-center justify-content-center flex-shrink-0"
54
+ style={{ width: 44, height: 44 }}
55
+ aria-hidden="true"
56
+ >
57
+ <i className={`bi ${props.isThisDevice ? "bi-laptop-fill" : "bi-laptop"}`} />
58
+ </span>
59
+ <span className="flex-grow-1 overflow-hidden">
60
+ <span className="d-flex align-items-center gap-2 flex-wrap">
61
+ <span className="fw-semibold text-truncate">
62
+ {props.device.name || props.device.reportedName || "Unnamed device"}
63
+ </span>
64
+ {props.isThisDevice ? <span className="badge text-bg-primary">This device</span> : null}
65
+ </span>
66
+ <span className="small text-muted d-block">
67
+ {props.device.lastSeen ? `Seen ${relativeTime(props.device.lastSeen)}` : "Never seen"}
68
+ </span>
69
+ </span>
70
+ <span className={`badge ${connected ? "text-bg-success" : "text-bg-secondary"}`}>
71
+ {connected ? "Online" : "Offline"}
72
+ </span>
73
+ {connected && props.connection?.pathType ? (
74
+ <span className="badge text-bg-light border text-capitalize">
75
+ {props.connection.pathType}
76
+ </span>
77
+ ) : null}
78
+ <i className="bi bi-chevron-right text-muted" aria-hidden="true" />
79
+ </a>
80
+ );
81
+ }
82
+
83
+ function ThisDeviceCard(props: { device: IDevice }) {
84
+ const name = useObservableState(props.device.name ?? "");
85
+ const [nameValue] = useObservable(name);
86
+ const [saving, setSaving] = useState(false);
87
+
88
+ useEffect(() => {
89
+ name(props.device.name ?? "");
90
+ }, [name, props.device.deviceId, props.device.name]);
91
+
92
+ const save = async () => {
93
+ setSaving(true);
94
+ try {
95
+ const userContext = await getUserContext();
96
+ const table = Devices(userContext.userDataContext);
97
+ const next = { ...props.device, name: nameValue.trim() || undefined };
98
+ await table.save(next);
99
+ } catch (err) {
100
+ alert(`Could not save this device: ${(err as Error).message}`);
101
+ } finally {
102
+ setSaving(false);
103
+ }
104
+ };
105
+
106
+ const dirty = (nameValue ?? "") !== (props.device.name ?? "");
107
+
108
+ return (
109
+ <div className="card mb-3">
110
+ <div className="card-body">
111
+ <h6 className="card-title">This device</h6>
112
+ <label className="form-label small" htmlFor="this-device-name">
113
+ Name
114
+ </label>
115
+ <div className="input-group">
116
+ <Input
117
+ id="this-device-name"
118
+ value={name}
119
+ className="form-control"
120
+ maxLength={DISPLAY_NAME_MAX_LENGTH}
121
+ placeholder="e.g. My laptop"
122
+ />
123
+ <button
124
+ type="button"
125
+ className="btn btn-primary"
126
+ disabled={!dirty || saving}
127
+ onClick={() => void save()}
128
+ >
129
+ Save
130
+ </button>
131
+ </div>
132
+ <div className="small text-muted mt-2">
133
+ {trustLabel(props.device.trustLevel)} · {props.device.deviceId}
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ /** Which devices surface the Identity shell is showing. */
141
+ export type DevicesSectionMode = "list" | "pair" | "detail";
142
+
143
+ /**
144
+ * Devices section of Identity: the account's devices, pairing, or one device.
145
+ */
146
+ export function DevicesSection(props: { mode?: DevicesSectionMode; deviceId?: string }) {
147
+ const mode = props.mode ?? "list";
148
+ const userContext = usePromise(getUserContext);
149
+ const [thisId] = useObservable(thisDeviceId);
150
+ const [devices, setDevices] = useState<IDevice[] | undefined>();
151
+ const [connections, setConnections] = useState<INetworkConnectionInfo[]>([]);
152
+
153
+ useEffect(() => {
154
+ if (!userContext) {
155
+ return;
156
+ }
157
+ let disposed = false;
158
+ const table = Devices(userContext.userDataContext);
159
+ const load = async () => {
160
+ const rows = await table.list();
161
+ if (!disposed) {
162
+ setDevices(rows);
163
+ }
164
+ };
165
+ void load();
166
+ const subscription = table.dataChanged.subscribe(() => {
167
+ void load();
168
+ });
169
+ return () => {
170
+ disposed = true;
171
+ subscription.unsubscribe();
172
+ };
173
+ }, [userContext]);
174
+
175
+ useEffect(() => {
176
+ let disposed = false;
177
+ const load = async () => {
178
+ try {
179
+ const diagnostics = await getNetworkDiagnosticsConsumer();
180
+ const result = await diagnostics.tools.getConnections();
181
+ if (!disposed) {
182
+ setConnections(result.connections);
183
+ }
184
+ } catch {
185
+ if (!disposed) {
186
+ setConnections([]);
187
+ }
188
+ }
189
+ };
190
+ void load();
191
+ const timer = window.setInterval(() => void load(), 5000);
192
+ return () => {
193
+ disposed = true;
194
+ window.clearInterval(timer);
195
+ };
196
+ }, []);
197
+
198
+ if (!userContext || !devices) {
199
+ return <LoadingIndicator />;
200
+ }
201
+
202
+ const byId = new Map(connections.map((connection) => [connection.deviceId, connection]));
203
+ const accountDevices = devicesForUser(devices, userContext.userId);
204
+ const mine = accountDevices.find((device) => device.deviceId === thisId);
205
+ const others = accountDevices
206
+ .filter((device) => device.deviceId !== thisId)
207
+ .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
208
+
209
+ if (mode === "pair") {
210
+ return (
211
+ <div className="container-fluid p-3" style={{ maxWidth: 720 }}>
212
+ <a href={`#${IDENTITY_DEVICES_PATH}`} className="btn btn-link btn-sm px-0 mb-2">
213
+ Back to devices
214
+ </a>
215
+ <SourceDevicePairing onClose={() => mainContentPath(IDENTITY_DEVICES_PATH)} />
216
+ </div>
217
+ );
218
+ }
219
+
220
+ if (mode === "detail") {
221
+ const device = accountDevices.find((entry) => entry.deviceId === props.deviceId);
222
+ if (!device) {
223
+ return (
224
+ <div className="container-fluid p-3">
225
+ <a href={`#${IDENTITY_DEVICES_PATH}`}>Back to devices</a>
226
+ <p className="mt-3 text-muted">That device is not on this account.</p>
227
+ </div>
228
+ );
229
+ }
230
+ return (
231
+ <DeviceDetail
232
+ device={device}
233
+ connection={byId.get(device.deviceId)}
234
+ isThisDevice={device.deviceId === thisId}
235
+ />
236
+ );
237
+ }
238
+
239
+ return (
240
+ <div className="container-xxl p-3">
241
+ <div className="mb-3">
242
+ <h4 className="mb-1">
243
+ <i className="bi bi-laptop me-2" />
244
+ My devices
245
+ </h4>
246
+ <div className="small text-muted">
247
+ Installations signed in to this account. Connection diagnostics stay in{" "}
248
+ <a href="#network-viewer">Network Viewer</a>.
249
+ </div>
250
+ </div>
251
+ <PendingWorkLink type="device" />
252
+ <div className="list-group">
253
+ {mine ? (
254
+ <DeviceRow device={mine} connection={byId.get(mine.deviceId)} isThisDevice />
255
+ ) : null}
256
+ {others.map((device) => (
257
+ <DeviceRow
258
+ key={device.deviceId}
259
+ device={device}
260
+ connection={byId.get(device.deviceId)}
261
+ isThisDevice={false}
262
+ />
263
+ ))}
264
+ </div>
265
+ {!mine && others.length === 0 ? (
266
+ <div className="border rounded p-4 text-center">
267
+ <i className="bi bi-laptop fs-2 text-muted" />
268
+ <h6 className="mt-2">No devices are registered yet</h6>
269
+ <a href={`#${IDENTITY_PAIR_PATH}`} className="btn btn-primary btn-sm">
270
+ Add a device
271
+ </a>
272
+ </div>
273
+ ) : null}
274
+ {mine && others.length === 0 ? (
275
+ <p className="small text-muted mt-3">
276
+ This is your only device. Use <a href={`#${IDENTITY_PAIR_PATH}`}>Add device</a> to sign in
277
+ somewhere else.
278
+ </p>
279
+ ) : null}
280
+ </div>
281
+ );
282
+ }
283
+
284
+ function DeviceDetail(props: {
285
+ device: IDevice;
286
+ connection?: INetworkConnectionInfo;
287
+ isThisDevice: boolean;
288
+ }) {
289
+ return (
290
+ <div className="container-xxl p-3">
291
+ <a href={`#${IDENTITY_DEVICES_PATH}`} className="btn btn-link btn-sm px-0 mb-2">
292
+ Back to devices
293
+ </a>
294
+ <h4 className="mb-3">{props.device.name || "Unnamed device"}</h4>
295
+ {props.isThisDevice ? <ThisDeviceCard device={props.device} /> : null}
296
+ <dl className="row small mb-3">
297
+ <dt className="col-sm-4">Trust</dt>
298
+ <dd className="col-sm-8">{trustLabel(props.device.trustLevel)}</dd>
299
+ <dt className="col-sm-4">Connection</dt>
300
+ <dd className="col-sm-8">{connectionLabel(props.connection)}</dd>
301
+ <dt className="col-sm-4">Last seen</dt>
302
+ <dd className="col-sm-8">
303
+ {props.device.lastSeen ? new Date(props.device.lastSeen).toLocaleString() : "Unknown"}
304
+ </dd>
305
+ <dt className="col-sm-4">Device ID</dt>
306
+ <dd className="col-sm-8 text-break">{props.device.deviceId}</dd>
307
+ </dl>
308
+ <a href="#network-viewer" className="btn btn-outline-secondary btn-sm me-2">
309
+ Open Network Viewer
310
+ </a>
311
+ {props.isThisDevice ? null : (
312
+ <button
313
+ type="button"
314
+ className="btn btn-outline-danger btn-sm"
315
+ onClick={() => void forgetDevice(props.device)}
316
+ >
317
+ Forget
318
+ </button>
319
+ )}
320
+ </div>
321
+ );
322
+ }
323
+
324
+ async function forgetDevice(device: IDevice) {
325
+ const confirmed = confirm(`Forget ${device.name || device.deviceId}?`);
326
+ if (!confirmed) {
327
+ return;
328
+ }
329
+ const control = await getNetworkControlConsumer();
330
+ const result = await control.tools.deleteDevice({ deviceId: device.deviceId });
331
+ if (!result.success) {
332
+ alert(result.message);
333
+ return;
334
+ }
335
+ mainContentPath(IDENTITY_DEVICES_PATH);
336
+ }
337
+
338
+ /** Keep only devices whose signed-in owner is the requested user. */
339
+ export function devicesForUser(devices: IDevice[], userId: string): IDevice[] {
340
+ return devices.filter((device) => device.userId === userId);
341
+ }
342
+
343
+ function relativeTime(value: Date): string {
344
+ const elapsedMs = Math.max(0, Date.now() - new Date(value).getTime());
345
+ const minutes = Math.floor(elapsedMs / 60_000);
346
+ if (minutes < 1) {
347
+ return "just now";
348
+ }
349
+ if (minutes < 60) {
350
+ return `${minutes}m ago`;
351
+ }
352
+ const hours = Math.floor(minutes / 60);
353
+ if (hours < 24) {
354
+ return `${hours}h ago`;
355
+ }
356
+ const days = Math.floor(hours / 24);
357
+ if (days < 30) {
358
+ return `${days}d ago`;
359
+ }
360
+ return new Date(value).toLocaleDateString();
361
+ }
@@ -0,0 +1 @@
1
+ export { DevicesSection } from "./devices-list";