@peers-app/peers-ui 0.21.3 → 0.21.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.
@@ -1,13 +1,35 @@
1
1
  import { type INetworkStatusViewState } from "./use-network-status";
2
+ /** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
3
+ export declare const SYNCING_GREEN_MS = 2000;
4
+ /** How long status must stay fully synced before leaving the syncing visual. */
5
+ export declare const SYNCED_STABLE_MS = 1000;
6
+ type NetworkStatusMark = "solid" | "pulse" | "indeterminate";
2
7
  interface INetworkStatusPresentation {
3
8
  label: string;
4
9
  shortLabel: string;
5
- iconClass: string;
6
10
  colorClass: string;
11
+ /** Bootstrap Icons class when not using a status mark. */
12
+ iconClass?: string;
7
13
  spinning?: boolean;
14
+ /** Size the Bootstrap icon to match the status marks. */
15
+ compactIcon?: boolean;
16
+ /** Solid / pulsing / indeterminate mark (syncing / error). */
17
+ mark?: NetworkStatusMark;
8
18
  }
9
19
  /** Convert host availability and network state into accessible indicator text and styling. */
10
- export declare function getNetworkStatusPresentation(viewState: INetworkStatusViewState): INetworkStatusPresentation;
20
+ export declare function getNetworkStatusPresentation(viewState: INetworkStatusViewState, opts?: {
21
+ syncingLongerThanGreenMs?: boolean;
22
+ /** Keep the calm green syncing pulse while waiting for stable synced. */
23
+ holdSyncingVisual?: boolean;
24
+ }): INetworkStatusPresentation;
25
+ /** Compact relative time for the status popover (always short enough for one line). */
26
+ export declare function formatRelativeSyncTime(timestampMs: number, nowMs?: number): string;
27
+ /** Prefer a friendly name; otherwise a compact middle-ellipsis of the id. */
28
+ export declare function connectionIdentityLabel(name: string | undefined, id: string): {
29
+ text: string;
30
+ isId: boolean;
31
+ title: string;
32
+ };
11
33
  /** Accessible reactive network status button and details popover. */
12
34
  export declare function NetworkStatusIndicator({ compact }: {
13
35
  compact?: boolean;
@@ -1,14 +1,29 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SYNCED_STABLE_MS = exports.SYNCING_GREEN_MS = void 0;
3
7
  exports.getNetworkStatusPresentation = getNetworkStatusPresentation;
8
+ exports.formatRelativeSyncTime = formatRelativeSyncTime;
9
+ exports.connectionIdentityLabel = connectionIdentityLabel;
4
10
  exports.NetworkStatusIndicator = NetworkStatusIndicator;
5
11
  exports.TabsStripIndicators = TabsStripIndicators;
6
12
  const jsx_runtime_1 = require("react/jsx-runtime");
13
+ const moment_1 = __importDefault(require("moment"));
7
14
  const react_1 = require("react");
15
+ const network_viewer_ipc_1 = require("../screens/network-viewer/network-viewer-ipc");
8
16
  const tabs_state_1 = require("./tabs-state");
9
17
  const use_network_status_1 = require("./use-network-status");
18
+ /** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
19
+ exports.SYNCING_GREEN_MS = 2_000;
20
+ /** How long status must stay fully synced before leaving the syncing visual. */
21
+ exports.SYNCED_STABLE_MS = 1_000;
22
+ /** Slightly slower than Bootstrap's default spinner-grow (~0.75s). */
23
+ const PULSE_ANIMATION_DURATION = "1.0s";
24
+ const DOT_SIZE = "0.7rem";
10
25
  /** Convert host availability and network state into accessible indicator text and styling. */
11
- function getNetworkStatusPresentation(viewState) {
26
+ function getNetworkStatusPresentation(viewState, opts) {
12
27
  if (viewState.availability === "loading") {
13
28
  return {
14
29
  label: "Network status loading",
@@ -31,17 +46,20 @@ function getNetworkStatusPresentation(viewState) {
31
46
  return {
32
47
  label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
33
48
  shortLabel: "Error",
34
- iconClass: "bi-exclamation-triangle-fill",
35
49
  colorClass: "text-danger",
50
+ mark: "solid",
36
51
  };
37
52
  }
38
- if (state === "syncing") {
53
+ if (state === "syncing" || opts?.holdSyncingVisual) {
54
+ const longSync = state === "syncing" && opts?.syncingLongerThanGreenMs === true;
55
+ const contextsLabel = state === "syncing"
56
+ ? `${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}`
57
+ : "recent activity";
39
58
  return {
40
- label: `Network syncing ${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
59
+ label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
41
60
  shortLabel: "Syncing",
42
- iconClass: "bi-arrow-repeat",
43
- colorClass: "text-primary",
44
- spinning: true,
61
+ colorClass: longSync ? "text-primary" : "text-success",
62
+ mark: longSync ? "indeterminate" : "pulse",
45
63
  };
46
64
  }
47
65
  if (state === "offline") {
@@ -57,18 +75,154 @@ function getNetworkStatusPresentation(viewState) {
57
75
  shortLabel: "Synced",
58
76
  iconClass: "bi-check-circle-fill",
59
77
  colorClass: "text-success",
78
+ compactIcon: true,
60
79
  };
61
80
  }
62
- function NetworkStatusDetails({ detailsId, status, unavailable, onOpenViewer, }) {
63
- return ((0, jsx_runtime_1.jsx)("div", { id: detailsId, role: "dialog", "aria-label": "Network status details", className: "card shadow position-absolute end-0 mt-1", style: { top: "100%", width: "280px", zIndex: 1100 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-3", children: [(0, jsx_runtime_1.jsx)("h6", { className: "card-title mb-2", children: "Network status" }), unavailable || !status ? ((0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-3", children: "Network diagnostics are temporarily unavailable. The local host may still be starting or reconnecting." })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-2", children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Connections" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.connectionCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Syncing contexts" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.syncingContextCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Sync errors" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.errorCount })] }), status.lastSuccessfulSyncAt && ((0, jsx_runtime_1.jsxs)("p", { className: "small text-muted mb-2", children: ["Last successful sync: ", new Date(status.lastSuccessfulSyncAt).toLocaleString()] })), status.errors.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger py-2 px-2 small mb-2", children: [status.errors[0].message, status.errors.length > 1 && ` (+${status.errors.length - 1} more)`] }))] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-primary w-100", onClick: onOpenViewer, children: "Open Network Viewer" })] }) }));
81
+ function StatusMark({ mode, colorClass }) {
82
+ if (mode === "pulse") {
83
+ return ((0, jsx_runtime_1.jsx)("span", { className: `spinner-grow spinner-grow-sm ${colorClass}`, style: {
84
+ width: DOT_SIZE,
85
+ height: DOT_SIZE,
86
+ animationDuration: PULSE_ANIMATION_DURATION,
87
+ }, "aria-hidden": "true" }));
88
+ }
89
+ if (mode === "indeterminate") {
90
+ return ((0, jsx_runtime_1.jsx)("span", { className: `spinner-border spinner-border-sm ${colorClass}`, style: { width: DOT_SIZE, height: DOT_SIZE, borderWidth: "0.12em" }, "aria-hidden": "true" }));
91
+ }
92
+ const bgClass = colorClass === "text-danger"
93
+ ? "bg-danger"
94
+ : colorClass === "text-primary"
95
+ ? "bg-primary"
96
+ : "bg-success";
97
+ return ((0, jsx_runtime_1.jsx)("span", { className: `rounded-circle ${bgClass}`, style: { width: DOT_SIZE, height: DOT_SIZE, display: "inline-block" }, "aria-hidden": "true" }));
98
+ }
99
+ /** Compact relative time for the status popover (always short enough for one line). */
100
+ function formatRelativeSyncTime(timestampMs, nowMs = Date.now()) {
101
+ const then = (0, moment_1.default)(timestampMs);
102
+ const now = (0, moment_1.default)(nowMs);
103
+ if (now.diff(then, "seconds") < 45) {
104
+ return "now";
105
+ }
106
+ if (now.clone().subtract(1, "day").isSame(then, "day")) {
107
+ return "yesterday";
108
+ }
109
+ return then.from(now);
110
+ }
111
+ function middleEllipsis(value, head = 4, tail = 4) {
112
+ if (value.length <= head + tail + 1) {
113
+ return value;
114
+ }
115
+ return `${value.slice(0, head)}…${value.slice(-tail)}`;
116
+ }
117
+ /** Prefer a friendly name; otherwise a compact middle-ellipsis of the id. */
118
+ function connectionIdentityLabel(name, id) {
119
+ const trimmed = name?.trim();
120
+ if (trimmed) {
121
+ return { text: trimmed, isId: false, title: trimmed };
122
+ }
123
+ return { text: middleEllipsis(id), isId: true, title: id };
124
+ }
125
+ function IdentityPart({ label }) {
126
+ return ((0, jsx_runtime_1.jsx)("span", { className: `text-truncate ${label.isId ? "text-muted" : ""}`, style: {
127
+ minWidth: 0,
128
+ ...(label.isId ? { fontSize: "0.7rem" } : undefined),
129
+ }, title: label.title, children: label.text }));
130
+ }
131
+ function ConnectedDeviceRow({ connection }) {
132
+ const user = connectionIdentityLabel(connection.userName, connection.userId);
133
+ const device = connectionIdentityLabel(connection.deviceName, connection.deviceId);
134
+ const groupCount = connection.sharedGroups.length;
135
+ return ((0, jsx_runtime_1.jsxs)("li", { className: "d-flex align-items-baseline gap-1 py-1", style: { listStyle: "none", minWidth: 0 }, children: [(0, jsx_runtime_1.jsx)(IdentityPart, { label: user }), (0, jsx_runtime_1.jsx)("span", { className: "text-muted flex-shrink-0", "aria-hidden": "true", children: "\u00B7" }), (0, jsx_runtime_1.jsx)(IdentityPart, { label: device }), groupCount > 0 && ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted flex-shrink-0 ms-auto", style: { fontSize: "0.7rem" }, title: `${groupCount} ${groupCount === 1 ? "group" : "groups"}`, children: [groupCount, "g"] }))] }));
136
+ }
137
+ function ConnectedDevicesList({ loadState }) {
138
+ if (loadState.status === "idle" || loadState.status === "loading") {
139
+ return (0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-2", children: "Loading\u2026" });
140
+ }
141
+ if (loadState.status === "error") {
142
+ return (0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-2", children: "Couldn't load devices" });
143
+ }
144
+ if (loadState.connections.length === 0) {
145
+ return (0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-2", children: "No connected devices" });
146
+ }
147
+ return ((0, jsx_runtime_1.jsx)("ul", { className: "small mb-0 ps-0", style: { maxHeight: "180px", overflowY: "auto" }, children: loadState.connections.map((connection) => ((0, jsx_runtime_1.jsx)(ConnectedDeviceRow, { connection: connection }, connection.deviceId))) }));
148
+ }
149
+ function NetworkStatusDetails({ detailsId, status, unavailable, connectionsLoadState, onOpenViewer, }) {
150
+ return ((0, jsx_runtime_1.jsx)("div", { id: detailsId, role: "dialog", "aria-label": "Network status details", className: "card shadow position-absolute end-0 mt-1", style: { top: "100%", width: "320px", zIndex: 1100 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-3", children: [(0, jsx_runtime_1.jsx)("h6", { className: "card-title mb-2", children: "Network status" }), unavailable || !status ? ((0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-3", children: "Network diagnostics are temporarily unavailable. The local host may still be starting or reconnecting." })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("dl", { className: "row small mb-2", children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Connections" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.connectionCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Syncing contexts" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.syncingContextCount }), (0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Sync errors" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end", children: status.errorCount }), status.lastSuccessfulSyncAt != null && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("dt", { className: "col-7", children: "Last sync" }), (0, jsx_runtime_1.jsx)("dd", { className: "col-5 text-end text-truncate", title: new Date(status.lastSuccessfulSyncAt).toLocaleString(), children: formatRelativeSyncTime(status.lastSuccessfulSyncAt) })] }))] }), status.errors.length > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger py-2 px-2 small mb-2", children: [status.errors[0].message, status.errors.length > 1 && ` (+${status.errors.length - 1} more)`] })), (0, jsx_runtime_1.jsxs)("div", { className: "border-top pt-2 mt-1 mb-2", children: [(0, jsx_runtime_1.jsx)("div", { className: "small text-muted mb-1", children: "Devices" }), (0, jsx_runtime_1.jsx)(ConnectedDevicesList, { loadState: connectionsLoadState })] })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm btn-outline-primary w-100", onClick: onOpenViewer, children: "Open Network Viewer" })] }) }));
64
151
  }
65
152
  /** Accessible reactive network status button and details popover. */
66
153
  function NetworkStatusIndicator({ compact = false }) {
67
154
  const viewState = (0, use_network_status_1.useNetworkStatus)();
68
- const presentation = getNetworkStatusPresentation(viewState);
155
+ const [displaySyncing, setDisplaySyncing] = (0, react_1.useState)(false);
156
+ const [syncingLongerThanGreenMs, setSyncingLongerThanGreenMs] = (0, react_1.useState)(false);
69
157
  const [open, setOpen] = (0, react_1.useState)(false);
158
+ const [connectionsLoadState, setConnectionsLoadState] = (0, react_1.useState)({
159
+ status: "idle",
160
+ });
70
161
  const containerRef = (0, react_1.useRef)(null);
71
162
  const detailsId = (0, react_1.useId)();
163
+ const rawState = viewState.availability === "ready" ? viewState.status?.state : undefined;
164
+ const rawIsSyncing = rawState === "syncing";
165
+ const rawIsSynced = rawState === "synced";
166
+ const connectionCount = viewState.status?.connectionCount;
167
+ const diagnosticsReady = viewState.availability === "ready";
168
+ (0, react_1.useEffect)(() => {
169
+ if (rawIsSyncing) {
170
+ setDisplaySyncing(true);
171
+ return;
172
+ }
173
+ if (rawIsSynced) {
174
+ if (!displaySyncing) {
175
+ return;
176
+ }
177
+ const timer = window.setTimeout(() => {
178
+ setDisplaySyncing(false);
179
+ }, exports.SYNCED_STABLE_MS);
180
+ return () => {
181
+ window.clearTimeout(timer);
182
+ };
183
+ }
184
+ setDisplaySyncing(false);
185
+ }, [rawIsSyncing, rawIsSynced, displaySyncing]);
186
+ (0, react_1.useEffect)(() => {
187
+ if (!rawIsSyncing) {
188
+ setSyncingLongerThanGreenMs(false);
189
+ return;
190
+ }
191
+ const timer = window.setTimeout(() => {
192
+ setSyncingLongerThanGreenMs(true);
193
+ }, exports.SYNCING_GREEN_MS);
194
+ return () => {
195
+ window.clearTimeout(timer);
196
+ };
197
+ }, [rawIsSyncing]);
198
+ (0, react_1.useEffect)(() => {
199
+ if (!open || !diagnosticsReady) {
200
+ setConnectionsLoadState({ status: "idle" });
201
+ return;
202
+ }
203
+ let active = true;
204
+ setConnectionsLoadState({ status: "loading" });
205
+ void (0, network_viewer_ipc_1.getNetworkDiagnosticsConsumer)()
206
+ .then((diagnostics) => diagnostics.tools.getConnections())
207
+ .then((result) => {
208
+ if (active) {
209
+ setConnectionsLoadState({ status: "ready", connections: result.connections });
210
+ }
211
+ })
212
+ .catch(() => {
213
+ if (active) {
214
+ setConnectionsLoadState({ status: "error" });
215
+ }
216
+ });
217
+ return () => {
218
+ active = false;
219
+ };
220
+ }, [open, diagnosticsReady, connectionCount]);
221
+ const holdSyncingVisual = displaySyncing && !rawIsSyncing;
222
+ const presentation = getNetworkStatusPresentation(viewState, {
223
+ syncingLongerThanGreenMs,
224
+ holdSyncingVisual,
225
+ });
72
226
  (0, react_1.useEffect)(() => {
73
227
  if (!open) {
74
228
  return;
@@ -94,7 +248,9 @@ function NetworkStatusIndicator({ compact = false }) {
94
248
  border: "none",
95
249
  background: "transparent",
96
250
  minWidth: compact ? "30px" : undefined,
97
- }, "aria-label": presentation.label, "aria-expanded": open, "aria-controls": detailsId, title: presentation.label, onClick: () => setOpen((current) => !current), children: [(0, jsx_runtime_1.jsx)("i", { className: `bi ${presentation.iconClass} ${presentation.spinning ? "spin" : ""}`, "aria-hidden": "true" }), !compact && ((0, jsx_runtime_1.jsxs)("span", { className: "small", children: [viewState.status?.connectionCount ?? 0, (0, jsx_runtime_1.jsx)("span", { className: "visually-hidden", children: " connections" })] }))] }), open && ((0, jsx_runtime_1.jsx)(NetworkStatusDetails, { detailsId: detailsId, status: viewState.status, unavailable: viewState.availability !== "ready", onOpenViewer: () => {
251
+ }, "aria-label": presentation.label, "aria-expanded": open, "aria-controls": detailsId, title: presentation.label, onClick: () => setOpen((current) => !current), children: [presentation.mark ? ((0, jsx_runtime_1.jsx)(StatusMark, { mode: presentation.mark, colorClass: presentation.colorClass })) : ((0, jsx_runtime_1.jsx)("i", { className: `bi ${presentation.iconClass} ${presentation.spinning ? "spin" : ""}`, style: presentation.compactIcon
252
+ ? { fontSize: DOT_SIZE, lineHeight: 1, display: "inline-block" }
253
+ : undefined, "aria-hidden": "true" })), !compact && ((0, jsx_runtime_1.jsxs)("span", { className: "small", children: [viewState.status?.connectionCount ?? 0, (0, jsx_runtime_1.jsx)("span", { className: "visually-hidden", children: " connections" })] }))] }), open && ((0, jsx_runtime_1.jsx)(NetworkStatusDetails, { detailsId: detailsId, status: viewState.status, unavailable: viewState.availability !== "ready", connectionsLoadState: connectionsLoadState, onOpenViewer: () => {
98
254
  setOpen(false);
99
255
  (0, tabs_state_1.goToTabPath)("network-viewer");
100
256
  } }))] }));
@@ -1,9 +1,15 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
4
8
  const react_1 = require("@testing-library/react");
9
+ const moment_1 = __importDefault(require("moment"));
5
10
  const network_status_indicator_1 = require("./network-status-indicator");
6
11
  const goToTabPath = jest.fn();
12
+ const getConnections = jest.fn();
7
13
  let mockViewState = { availability: "loading" };
8
14
  jest.mock("./tabs-state", () => ({
9
15
  goToTabPath: (...args) => goToTabPath(...args),
@@ -11,6 +17,13 @@ jest.mock("./tabs-state", () => ({
11
17
  jest.mock("./use-network-status", () => ({
12
18
  useNetworkStatus: () => mockViewState,
13
19
  }));
20
+ jest.mock("../screens/network-viewer/network-viewer-ipc", () => ({
21
+ getNetworkDiagnosticsConsumer: () => Promise.resolve({
22
+ tools: {
23
+ getConnections: (...args) => getConnections(...args),
24
+ },
25
+ }),
26
+ }));
14
27
  function status(overrides) {
15
28
  return {
16
29
  state: "synced",
@@ -21,11 +34,115 @@ function status(overrides) {
21
34
  ...overrides,
22
35
  };
23
36
  }
37
+ function connection(overrides = {}) {
38
+ return {
39
+ deviceId: "device-1",
40
+ userId: "user-1",
41
+ userName: "Ada",
42
+ deviceName: "Laptop",
43
+ userTrustLevel: peers_sdk_1.TrustLevel.Trusted,
44
+ deviceTrustLevel: peers_sdk_1.TrustLevel.Trusted,
45
+ connectionState: "connected",
46
+ sharedGroups: ["group-a"],
47
+ roles: {},
48
+ pathType: "direct",
49
+ latencyMs: 10,
50
+ errorRate: 0,
51
+ isServer: false,
52
+ ...overrides,
53
+ };
54
+ }
55
+ describe("formatRelativeSyncTime", () => {
56
+ it("returns now for very recent syncs", () => {
57
+ const now = Date.parse("2026-07-28T23:43:06.000");
58
+ expect((0, network_status_indicator_1.formatRelativeSyncTime)(now - 10_000, now)).toBe("now");
59
+ });
60
+ it("returns yesterday for the previous calendar day", () => {
61
+ const now = (0, moment_1.default)("2026-07-28T23:43:06").valueOf();
62
+ const yesterdayAfternoon = (0, moment_1.default)("2026-07-28T23:43:06")
63
+ .subtract(1, "day")
64
+ .hour(18)
65
+ .minute(0)
66
+ .second(0)
67
+ .valueOf();
68
+ expect((0, network_status_indicator_1.formatRelativeSyncTime)(yesterdayAfternoon, now)).toBe("yesterday");
69
+ });
70
+ it("returns a short relative phrase otherwise", () => {
71
+ const now = Date.parse("2026-07-28T23:43:06.000");
72
+ expect((0, network_status_indicator_1.formatRelativeSyncTime)(now - 5 * 60_000, now)).toBe("5 minutes ago");
73
+ });
74
+ });
75
+ describe("connectionIdentityLabel", () => {
76
+ it("prefers a friendly name", () => {
77
+ expect((0, network_status_indicator_1.connectionIdentityLabel)("Ada", "00mabcdefghijklmnopqrs")).toEqual({
78
+ text: "Ada",
79
+ isId: false,
80
+ title: "Ada",
81
+ });
82
+ });
83
+ it("middle-ellipsizes raw ids", () => {
84
+ expect((0, network_status_indicator_1.connectionIdentityLabel)(undefined, "00mabcdefghijklmnopqrs")).toEqual({
85
+ text: "00ma…pqrs",
86
+ isId: true,
87
+ title: "00mabcdefghijklmnopqrs",
88
+ });
89
+ });
90
+ });
91
+ describe("getNetworkStatusPresentation", () => {
92
+ it("uses a compact green check-circle when synced", () => {
93
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({ availability: "ready", status: status({ state: "synced" }) })).toMatchObject({
94
+ shortLabel: "Synced",
95
+ colorClass: "text-success",
96
+ iconClass: "bi-check-circle-fill",
97
+ compactIcon: true,
98
+ });
99
+ });
100
+ it("uses a solid red dot when in error", () => {
101
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({
102
+ availability: "ready",
103
+ status: status({
104
+ state: "error",
105
+ errorCount: 1,
106
+ errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
107
+ }),
108
+ })).toMatchObject({
109
+ shortLabel: "Error",
110
+ colorClass: "text-danger",
111
+ mark: "solid",
112
+ });
113
+ });
114
+ it("pulses green then switches to an indeterminate circle while syncing", () => {
115
+ const syncing = {
116
+ availability: "ready",
117
+ status: status({ state: "syncing", syncingContextCount: 1 }),
118
+ };
119
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)(syncing)).toMatchObject({
120
+ colorClass: "text-success",
121
+ mark: "pulse",
122
+ });
123
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)(syncing, { syncingLongerThanGreenMs: true })).toMatchObject({
124
+ colorClass: "text-primary",
125
+ mark: "indeterminate",
126
+ });
127
+ });
128
+ it("holds a green pulse while waiting for stable synced", () => {
129
+ expect((0, network_status_indicator_1.getNetworkStatusPresentation)({ availability: "ready", status: status({ state: "synced" }) }, { holdSyncingVisual: true, syncingLongerThanGreenMs: true })).toMatchObject({
130
+ shortLabel: "Syncing",
131
+ colorClass: "text-success",
132
+ mark: "pulse",
133
+ });
134
+ });
135
+ });
24
136
  describe("NetworkStatusIndicator", () => {
25
137
  beforeEach(() => {
26
138
  mockViewState = { availability: "loading" };
139
+ getConnections.mockReset();
140
+ getConnections.mockResolvedValue({ connections: [] });
27
141
  jest.clearAllMocks();
28
142
  });
143
+ afterEach(() => {
144
+ jest.useRealTimers();
145
+ });
29
146
  it.each([
30
147
  [{ availability: "loading" }, /Network status loading/],
31
148
  [{ availability: "unavailable" }, /Network status unavailable/],
@@ -57,7 +174,139 @@ describe("NetworkStatusIndicator", () => {
57
174
  const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
58
175
  expect(view.getByRole("button", { name: accessibleName })).toBeTruthy();
59
176
  });
60
- it("updates on rerender and opens details before navigating to Network Viewer", () => {
177
+ it("renders a compact green check-circle when synced and a solid red error dot", () => {
178
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
179
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
180
+ const syncedIcon = view.container.querySelector("i.bi-check-circle-fill");
181
+ expect(syncedIcon).toBeTruthy();
182
+ expect(syncedIcon).toHaveStyle({ fontSize: "0.7rem" });
183
+ expect(view.container.querySelector(".spinner-grow")).toBeNull();
184
+ mockViewState = {
185
+ availability: "ready",
186
+ status: status({
187
+ state: "error",
188
+ errorCount: 1,
189
+ errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
190
+ }),
191
+ };
192
+ view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
193
+ expect(view.container.querySelector(".bg-danger.rounded-circle")).toBeTruthy();
194
+ });
195
+ it("uses a slower green pulse while syncing", () => {
196
+ mockViewState = {
197
+ availability: "ready",
198
+ status: status({ state: "syncing", syncingContextCount: 1 }),
199
+ };
200
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
201
+ const pulse = view.container.querySelector(".spinner-grow.text-success");
202
+ expect(pulse).toHaveStyle({ animationDuration: "1.0s" });
203
+ });
204
+ it("pulses green while syncing, then an indeterminate circle after the green window", () => {
205
+ jest.useFakeTimers();
206
+ mockViewState = {
207
+ availability: "ready",
208
+ status: status({ state: "syncing", syncingContextCount: 1 }),
209
+ };
210
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
211
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
212
+ (0, react_1.act)(() => {
213
+ jest.advanceTimersByTime(network_status_indicator_1.SYNCING_GREEN_MS);
214
+ });
215
+ expect(view.container.querySelector(".spinner-border.text-primary")).toBeTruthy();
216
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeNull();
217
+ });
218
+ it("keeps the green syncing pulse through brief synced flaps until synced is stable", () => {
219
+ jest.useFakeTimers();
220
+ mockViewState = {
221
+ availability: "ready",
222
+ status: status({ state: "syncing", syncingContextCount: 1 }),
223
+ };
224
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
225
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
226
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
227
+ view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
228
+ (0, react_1.act)(() => {
229
+ jest.advanceTimersByTime(network_status_indicator_1.SYNCED_STABLE_MS - 1);
230
+ });
231
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
232
+ expect(view.container.querySelector("i.bi-check-circle-fill")).toBeNull();
233
+ mockViewState = {
234
+ availability: "ready",
235
+ status: status({ state: "syncing", syncingContextCount: 1 }),
236
+ };
237
+ view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
238
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
239
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
240
+ view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
241
+ (0, react_1.act)(() => {
242
+ jest.advanceTimersByTime(network_status_indicator_1.SYNCED_STABLE_MS);
243
+ });
244
+ expect(view.container.querySelector("i.bi-check-circle-fill")).toBeTruthy();
245
+ expect(view.container.querySelector(".spinner-grow")).toBeNull();
246
+ });
247
+ it("lists connected devices in the details popover", async () => {
248
+ getConnections.mockResolvedValue({
249
+ connections: [
250
+ connection({
251
+ deviceId: "device-1",
252
+ userName: "Ada",
253
+ deviceName: "Laptop",
254
+ sharedGroups: ["g1", "g2"],
255
+ }),
256
+ ],
257
+ });
258
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
259
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
260
+ react_1.fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
261
+ await (0, react_1.waitFor)(() => {
262
+ expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent("Ada");
263
+ });
264
+ expect(view.getByRole("dialog")).toHaveTextContent("Laptop");
265
+ expect(view.getByRole("dialog")).toHaveTextContent("2g");
266
+ expect(getConnections).toHaveBeenCalled();
267
+ });
268
+ it("shows middle-ellipsized ids when names are missing", async () => {
269
+ getConnections.mockResolvedValue({
270
+ connections: [
271
+ connection({
272
+ deviceId: "00mdeviceidabcdefghijklm",
273
+ userId: "00museridabcdefghijklmn",
274
+ userName: undefined,
275
+ deviceName: undefined,
276
+ sharedGroups: [],
277
+ }),
278
+ ],
279
+ });
280
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
281
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
282
+ react_1.fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
283
+ await (0, react_1.waitFor)(() => {
284
+ expect(view.getByRole("dialog")).toHaveTextContent("00mu…klmn");
285
+ });
286
+ expect(view.getByRole("dialog")).toHaveTextContent("00md…jklm");
287
+ });
288
+ it("shows an empty connected-devices message when there are none", async () => {
289
+ getConnections.mockResolvedValue({ connections: [] });
290
+ mockViewState = {
291
+ availability: "ready",
292
+ status: status({ state: "offline", connectionCount: 0 }),
293
+ };
294
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
295
+ react_1.fireEvent.click(view.getByRole("button", { name: /Network offline/ }));
296
+ await (0, react_1.waitFor)(() => {
297
+ expect(view.getByRole("dialog")).toHaveTextContent("No connected devices");
298
+ });
299
+ });
300
+ it("shows an error when connected devices fail to load", async () => {
301
+ getConnections.mockRejectedValue(new Error("offline"));
302
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
303
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
304
+ react_1.fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
305
+ await (0, react_1.waitFor)(() => {
306
+ expect(view.getByRole("dialog")).toHaveTextContent("Couldn't load devices");
307
+ });
308
+ });
309
+ it("updates on rerender and opens details before navigating to Network Viewer", async () => {
61
310
  const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
62
311
  expect(view.getByRole("button", { name: /loading/ })).toBeTruthy();
63
312
  mockViewState = {
@@ -72,6 +321,9 @@ describe("NetworkStatusIndicator", () => {
72
321
  view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
73
322
  react_1.fireEvent.click(view.getByRole("button", { name: /Network error/ }));
74
323
  expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent("sync failed");
324
+ await (0, react_1.waitFor)(() => {
325
+ expect(getConnections).toHaveBeenCalled();
326
+ });
75
327
  react_1.fireEvent.keyDown(document, { key: "Escape" });
76
328
  expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
77
329
  react_1.fireEvent.click(view.getByRole("button", { name: /Network error/ }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.21.3",
31
+ "@peers-app/peers-sdk": "^0.21.5",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-react": "^7.24.1",
40
40
  "@babel/preset-typescript": "^7.27.1",
41
41
  "@biomejs/biome": "2.3.11",
42
- "@peers-app/peers-sdk": "0.21.3",
42
+ "@peers-app/peers-sdk": "0.21.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",
@@ -1,9 +1,19 @@
1
- import type { INetworkStatusSummary } from "@peers-app/peers-sdk";
2
- import { fireEvent, render } from "@testing-library/react";
3
- import { NetworkStatusIndicator } from "./network-status-indicator";
1
+ import type { INetworkConnectionInfo, INetworkStatusSummary } from "@peers-app/peers-sdk";
2
+ import { TrustLevel } from "@peers-app/peers-sdk";
3
+ import { act, fireEvent, render, waitFor } from "@testing-library/react";
4
+ import moment from "moment";
5
+ import {
6
+ connectionIdentityLabel,
7
+ formatRelativeSyncTime,
8
+ getNetworkStatusPresentation,
9
+ NetworkStatusIndicator,
10
+ SYNCED_STABLE_MS,
11
+ SYNCING_GREEN_MS,
12
+ } from "./network-status-indicator";
4
13
  import type { INetworkStatusViewState } from "./use-network-status";
5
14
 
6
15
  const goToTabPath = jest.fn();
16
+ const getConnections = jest.fn();
7
17
  let mockViewState: INetworkStatusViewState = { availability: "loading" };
8
18
 
9
19
  jest.mock("./tabs-state", () => ({
@@ -14,6 +24,15 @@ jest.mock("./use-network-status", () => ({
14
24
  useNetworkStatus: () => mockViewState,
15
25
  }));
16
26
 
27
+ jest.mock("../screens/network-viewer/network-viewer-ipc", () => ({
28
+ getNetworkDiagnosticsConsumer: () =>
29
+ Promise.resolve({
30
+ tools: {
31
+ getConnections: (...args: unknown[]) => getConnections(...args),
32
+ },
33
+ }),
34
+ }));
35
+
17
36
  function status(overrides: Partial<INetworkStatusSummary>): INetworkStatusSummary {
18
37
  return {
19
38
  state: "synced",
@@ -25,12 +44,138 @@ function status(overrides: Partial<INetworkStatusSummary>): INetworkStatusSummar
25
44
  };
26
45
  }
27
46
 
47
+ function connection(overrides: Partial<INetworkConnectionInfo> = {}): INetworkConnectionInfo {
48
+ return {
49
+ deviceId: "device-1",
50
+ userId: "user-1",
51
+ userName: "Ada",
52
+ deviceName: "Laptop",
53
+ userTrustLevel: TrustLevel.Trusted,
54
+ deviceTrustLevel: TrustLevel.Trusted,
55
+ connectionState: "connected",
56
+ sharedGroups: ["group-a"],
57
+ roles: {},
58
+ pathType: "direct",
59
+ latencyMs: 10,
60
+ errorRate: 0,
61
+ isServer: false,
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ describe("formatRelativeSyncTime", () => {
67
+ it("returns now for very recent syncs", () => {
68
+ const now = Date.parse("2026-07-28T23:43:06.000");
69
+ expect(formatRelativeSyncTime(now - 10_000, now)).toBe("now");
70
+ });
71
+
72
+ it("returns yesterday for the previous calendar day", () => {
73
+ const now = moment("2026-07-28T23:43:06").valueOf();
74
+ const yesterdayAfternoon = moment("2026-07-28T23:43:06")
75
+ .subtract(1, "day")
76
+ .hour(18)
77
+ .minute(0)
78
+ .second(0)
79
+ .valueOf();
80
+ expect(formatRelativeSyncTime(yesterdayAfternoon, now)).toBe("yesterday");
81
+ });
82
+
83
+ it("returns a short relative phrase otherwise", () => {
84
+ const now = Date.parse("2026-07-28T23:43:06.000");
85
+ expect(formatRelativeSyncTime(now - 5 * 60_000, now)).toBe("5 minutes ago");
86
+ });
87
+ });
88
+
89
+ describe("connectionIdentityLabel", () => {
90
+ it("prefers a friendly name", () => {
91
+ expect(connectionIdentityLabel("Ada", "00mabcdefghijklmnopqrs")).toEqual({
92
+ text: "Ada",
93
+ isId: false,
94
+ title: "Ada",
95
+ });
96
+ });
97
+
98
+ it("middle-ellipsizes raw ids", () => {
99
+ expect(connectionIdentityLabel(undefined, "00mabcdefghijklmnopqrs")).toEqual({
100
+ text: "00ma…pqrs",
101
+ isId: true,
102
+ title: "00mabcdefghijklmnopqrs",
103
+ });
104
+ });
105
+ });
106
+
107
+ describe("getNetworkStatusPresentation", () => {
108
+ it("uses a compact green check-circle when synced", () => {
109
+ expect(
110
+ getNetworkStatusPresentation({ availability: "ready", status: status({ state: "synced" }) }),
111
+ ).toMatchObject({
112
+ shortLabel: "Synced",
113
+ colorClass: "text-success",
114
+ iconClass: "bi-check-circle-fill",
115
+ compactIcon: true,
116
+ });
117
+ });
118
+
119
+ it("uses a solid red dot when in error", () => {
120
+ expect(
121
+ getNetworkStatusPresentation({
122
+ availability: "ready",
123
+ status: status({
124
+ state: "error",
125
+ errorCount: 1,
126
+ errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
127
+ }),
128
+ }),
129
+ ).toMatchObject({
130
+ shortLabel: "Error",
131
+ colorClass: "text-danger",
132
+ mark: "solid",
133
+ });
134
+ });
135
+
136
+ it("pulses green then switches to an indeterminate circle while syncing", () => {
137
+ const syncing: INetworkStatusViewState = {
138
+ availability: "ready",
139
+ status: status({ state: "syncing", syncingContextCount: 1 }),
140
+ };
141
+ expect(getNetworkStatusPresentation(syncing)).toMatchObject({
142
+ colorClass: "text-success",
143
+ mark: "pulse",
144
+ });
145
+ expect(getNetworkStatusPresentation(syncing, { syncingLongerThanGreenMs: true })).toMatchObject(
146
+ {
147
+ colorClass: "text-primary",
148
+ mark: "indeterminate",
149
+ },
150
+ );
151
+ });
152
+
153
+ it("holds a green pulse while waiting for stable synced", () => {
154
+ expect(
155
+ getNetworkStatusPresentation(
156
+ { availability: "ready", status: status({ state: "synced" }) },
157
+ { holdSyncingVisual: true, syncingLongerThanGreenMs: true },
158
+ ),
159
+ ).toMatchObject({
160
+ shortLabel: "Syncing",
161
+ colorClass: "text-success",
162
+ mark: "pulse",
163
+ });
164
+ });
165
+ });
166
+
28
167
  describe("NetworkStatusIndicator", () => {
29
168
  beforeEach(() => {
30
169
  mockViewState = { availability: "loading" };
170
+ getConnections.mockReset();
171
+ getConnections.mockResolvedValue({ connections: [] });
31
172
  jest.clearAllMocks();
32
173
  });
33
174
 
175
+ afterEach(() => {
176
+ jest.useRealTimers();
177
+ });
178
+
34
179
  it.each([
35
180
  [{ availability: "loading" }, /Network status loading/],
36
181
  [{ availability: "unavailable" }, /Network status unavailable/],
@@ -65,7 +210,156 @@ describe("NetworkStatusIndicator", () => {
65
210
  expect(view.getByRole("button", { name: accessibleName })).toBeTruthy();
66
211
  });
67
212
 
68
- it("updates on rerender and opens details before navigating to Network Viewer", () => {
213
+ it("renders a compact green check-circle when synced and a solid red error dot", () => {
214
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
215
+ const view = render(<NetworkStatusIndicator />);
216
+ const syncedIcon = view.container.querySelector("i.bi-check-circle-fill");
217
+ expect(syncedIcon).toBeTruthy();
218
+ expect(syncedIcon).toHaveStyle({ fontSize: "0.7rem" });
219
+ expect(view.container.querySelector(".spinner-grow")).toBeNull();
220
+
221
+ mockViewState = {
222
+ availability: "ready",
223
+ status: status({
224
+ state: "error",
225
+ errorCount: 1,
226
+ errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
227
+ }),
228
+ };
229
+ view.rerender(<NetworkStatusIndicator />);
230
+ expect(view.container.querySelector(".bg-danger.rounded-circle")).toBeTruthy();
231
+ });
232
+
233
+ it("uses a slower green pulse while syncing", () => {
234
+ mockViewState = {
235
+ availability: "ready",
236
+ status: status({ state: "syncing", syncingContextCount: 1 }),
237
+ };
238
+ const view = render(<NetworkStatusIndicator />);
239
+ const pulse = view.container.querySelector(".spinner-grow.text-success");
240
+ expect(pulse).toHaveStyle({ animationDuration: "1.0s" });
241
+ });
242
+
243
+ it("pulses green while syncing, then an indeterminate circle after the green window", () => {
244
+ jest.useFakeTimers();
245
+ mockViewState = {
246
+ availability: "ready",
247
+ status: status({ state: "syncing", syncingContextCount: 1 }),
248
+ };
249
+ const view = render(<NetworkStatusIndicator />);
250
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
251
+
252
+ act(() => {
253
+ jest.advanceTimersByTime(SYNCING_GREEN_MS);
254
+ });
255
+ expect(view.container.querySelector(".spinner-border.text-primary")).toBeTruthy();
256
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeNull();
257
+ });
258
+
259
+ it("keeps the green syncing pulse through brief synced flaps until synced is stable", () => {
260
+ jest.useFakeTimers();
261
+ mockViewState = {
262
+ availability: "ready",
263
+ status: status({ state: "syncing", syncingContextCount: 1 }),
264
+ };
265
+ const view = render(<NetworkStatusIndicator />);
266
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
267
+
268
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
269
+ view.rerender(<NetworkStatusIndicator />);
270
+ act(() => {
271
+ jest.advanceTimersByTime(SYNCED_STABLE_MS - 1);
272
+ });
273
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
274
+ expect(view.container.querySelector("i.bi-check-circle-fill")).toBeNull();
275
+
276
+ mockViewState = {
277
+ availability: "ready",
278
+ status: status({ state: "syncing", syncingContextCount: 1 }),
279
+ };
280
+ view.rerender(<NetworkStatusIndicator />);
281
+ expect(view.container.querySelector(".spinner-grow.text-success")).toBeTruthy();
282
+
283
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
284
+ view.rerender(<NetworkStatusIndicator />);
285
+ act(() => {
286
+ jest.advanceTimersByTime(SYNCED_STABLE_MS);
287
+ });
288
+ expect(view.container.querySelector("i.bi-check-circle-fill")).toBeTruthy();
289
+ expect(view.container.querySelector(".spinner-grow")).toBeNull();
290
+ });
291
+
292
+ it("lists connected devices in the details popover", async () => {
293
+ getConnections.mockResolvedValue({
294
+ connections: [
295
+ connection({
296
+ deviceId: "device-1",
297
+ userName: "Ada",
298
+ deviceName: "Laptop",
299
+ sharedGroups: ["g1", "g2"],
300
+ }),
301
+ ],
302
+ });
303
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
304
+ const view = render(<NetworkStatusIndicator />);
305
+ fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
306
+
307
+ await waitFor(() => {
308
+ expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent("Ada");
309
+ });
310
+ expect(view.getByRole("dialog")).toHaveTextContent("Laptop");
311
+ expect(view.getByRole("dialog")).toHaveTextContent("2g");
312
+ expect(getConnections).toHaveBeenCalled();
313
+ });
314
+
315
+ it("shows middle-ellipsized ids when names are missing", async () => {
316
+ getConnections.mockResolvedValue({
317
+ connections: [
318
+ connection({
319
+ deviceId: "00mdeviceidabcdefghijklm",
320
+ userId: "00museridabcdefghijklmn",
321
+ userName: undefined,
322
+ deviceName: undefined,
323
+ sharedGroups: [],
324
+ }),
325
+ ],
326
+ });
327
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
328
+ const view = render(<NetworkStatusIndicator />);
329
+ fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
330
+
331
+ await waitFor(() => {
332
+ expect(view.getByRole("dialog")).toHaveTextContent("00mu…klmn");
333
+ });
334
+ expect(view.getByRole("dialog")).toHaveTextContent("00md…jklm");
335
+ });
336
+
337
+ it("shows an empty connected-devices message when there are none", async () => {
338
+ getConnections.mockResolvedValue({ connections: [] });
339
+ mockViewState = {
340
+ availability: "ready",
341
+ status: status({ state: "offline", connectionCount: 0 }),
342
+ };
343
+ const view = render(<NetworkStatusIndicator />);
344
+ fireEvent.click(view.getByRole("button", { name: /Network offline/ }));
345
+
346
+ await waitFor(() => {
347
+ expect(view.getByRole("dialog")).toHaveTextContent("No connected devices");
348
+ });
349
+ });
350
+
351
+ it("shows an error when connected devices fail to load", async () => {
352
+ getConnections.mockRejectedValue(new Error("offline"));
353
+ mockViewState = { availability: "ready", status: status({ state: "synced" }) };
354
+ const view = render(<NetworkStatusIndicator />);
355
+ fireEvent.click(view.getByRole("button", { name: /Network synced/ }));
356
+
357
+ await waitFor(() => {
358
+ expect(view.getByRole("dialog")).toHaveTextContent("Couldn't load devices");
359
+ });
360
+ });
361
+
362
+ it("updates on rerender and opens details before navigating to Network Viewer", async () => {
69
363
  const view = render(<NetworkStatusIndicator />);
70
364
  expect(view.getByRole("button", { name: /loading/ })).toBeTruthy();
71
365
 
@@ -84,6 +378,9 @@ describe("NetworkStatusIndicator", () => {
84
378
  expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent(
85
379
  "sync failed",
86
380
  );
381
+ await waitFor(() => {
382
+ expect(getConnections).toHaveBeenCalled();
383
+ });
87
384
  fireEvent.keyDown(document, { key: "Escape" });
88
385
  expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
89
386
 
@@ -1,19 +1,50 @@
1
- import type { INetworkStatusSummary } from "@peers-app/peers-sdk";
1
+ import type { INetworkConnectionInfo, INetworkStatusSummary } from "@peers-app/peers-sdk";
2
+ import moment from "moment";
2
3
  import { useEffect, useId, useRef, useState } from "react";
4
+ import { getNetworkDiagnosticsConsumer } from "../screens/network-viewer/network-viewer-ipc";
3
5
  import { goToTabPath } from "./tabs-state";
4
6
  import { type INetworkStatusViewState, useNetworkStatus } from "./use-network-status";
5
7
 
8
+ /** How long continuous raw syncing stays a green pulse before the indeterminate circle. */
9
+ export const SYNCING_GREEN_MS = 2_000;
10
+
11
+ /** How long status must stay fully synced before leaving the syncing visual. */
12
+ export const SYNCED_STABLE_MS = 1_000;
13
+
14
+ /** Slightly slower than Bootstrap's default spinner-grow (~0.75s). */
15
+ const PULSE_ANIMATION_DURATION = "1.0s";
16
+
17
+ const DOT_SIZE = "0.7rem";
18
+
19
+ type NetworkStatusMark = "solid" | "pulse" | "indeterminate";
20
+
21
+ type ConnectionsLoadState =
22
+ | { status: "idle" }
23
+ | { status: "loading" }
24
+ | { status: "ready"; connections: INetworkConnectionInfo[] }
25
+ | { status: "error" };
26
+
6
27
  interface INetworkStatusPresentation {
7
28
  label: string;
8
29
  shortLabel: string;
9
- iconClass: string;
10
30
  colorClass: string;
31
+ /** Bootstrap Icons class when not using a status mark. */
32
+ iconClass?: string;
11
33
  spinning?: boolean;
34
+ /** Size the Bootstrap icon to match the status marks. */
35
+ compactIcon?: boolean;
36
+ /** Solid / pulsing / indeterminate mark (syncing / error). */
37
+ mark?: NetworkStatusMark;
12
38
  }
13
39
 
14
40
  /** Convert host availability and network state into accessible indicator text and styling. */
15
41
  export function getNetworkStatusPresentation(
16
42
  viewState: INetworkStatusViewState,
43
+ opts?: {
44
+ syncingLongerThanGreenMs?: boolean;
45
+ /** Keep the calm green syncing pulse while waiting for stable synced. */
46
+ holdSyncingVisual?: boolean;
47
+ },
17
48
  ): INetworkStatusPresentation {
18
49
  if (viewState.availability === "loading") {
19
50
  return {
@@ -37,17 +68,21 @@ export function getNetworkStatusPresentation(
37
68
  return {
38
69
  label: `Network error; ${errorCount} sync ${errorCount === 1 ? "error" : "errors"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
39
70
  shortLabel: "Error",
40
- iconClass: "bi-exclamation-triangle-fill",
41
71
  colorClass: "text-danger",
72
+ mark: "solid",
42
73
  };
43
74
  }
44
- if (state === "syncing") {
75
+ if (state === "syncing" || opts?.holdSyncingVisual) {
76
+ const longSync = state === "syncing" && opts?.syncingLongerThanGreenMs === true;
77
+ const contextsLabel =
78
+ state === "syncing"
79
+ ? `${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}`
80
+ : "recent activity";
45
81
  return {
46
- label: `Network syncing ${syncingContextCount} ${syncingContextCount === 1 ? "context" : "contexts"}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
82
+ label: `Network syncing ${contextsLabel}; ${connectionCount} active ${connectionCount === 1 ? "connection" : "connections"}`,
47
83
  shortLabel: "Syncing",
48
- iconClass: "bi-arrow-repeat",
49
- colorClass: "text-primary",
50
- spinning: true,
84
+ colorClass: longSync ? "text-primary" : "text-success",
85
+ mark: longSync ? "indeterminate" : "pulse",
51
86
  };
52
87
  }
53
88
  if (state === "offline") {
@@ -63,18 +98,153 @@ export function getNetworkStatusPresentation(
63
98
  shortLabel: "Synced",
64
99
  iconClass: "bi-check-circle-fill",
65
100
  colorClass: "text-success",
101
+ compactIcon: true,
66
102
  };
67
103
  }
68
104
 
105
+ function StatusMark({ mode, colorClass }: { mode: NetworkStatusMark; colorClass: string }) {
106
+ if (mode === "pulse") {
107
+ return (
108
+ <span
109
+ className={`spinner-grow spinner-grow-sm ${colorClass}`}
110
+ style={{
111
+ width: DOT_SIZE,
112
+ height: DOT_SIZE,
113
+ animationDuration: PULSE_ANIMATION_DURATION,
114
+ }}
115
+ aria-hidden="true"
116
+ />
117
+ );
118
+ }
119
+ if (mode === "indeterminate") {
120
+ return (
121
+ <span
122
+ className={`spinner-border spinner-border-sm ${colorClass}`}
123
+ style={{ width: DOT_SIZE, height: DOT_SIZE, borderWidth: "0.12em" }}
124
+ aria-hidden="true"
125
+ />
126
+ );
127
+ }
128
+ const bgClass =
129
+ colorClass === "text-danger"
130
+ ? "bg-danger"
131
+ : colorClass === "text-primary"
132
+ ? "bg-primary"
133
+ : "bg-success";
134
+ return (
135
+ <span
136
+ className={`rounded-circle ${bgClass}`}
137
+ style={{ width: DOT_SIZE, height: DOT_SIZE, display: "inline-block" }}
138
+ aria-hidden="true"
139
+ />
140
+ );
141
+ }
142
+
143
+ /** Compact relative time for the status popover (always short enough for one line). */
144
+ export function formatRelativeSyncTime(timestampMs: number, nowMs = Date.now()): string {
145
+ const then = moment(timestampMs);
146
+ const now = moment(nowMs);
147
+ if (now.diff(then, "seconds") < 45) {
148
+ return "now";
149
+ }
150
+ if (now.clone().subtract(1, "day").isSame(then, "day")) {
151
+ return "yesterday";
152
+ }
153
+ return then.from(now);
154
+ }
155
+
156
+ function middleEllipsis(value: string, head = 4, tail = 4): string {
157
+ if (value.length <= head + tail + 1) {
158
+ return value;
159
+ }
160
+ return `${value.slice(0, head)}…${value.slice(-tail)}`;
161
+ }
162
+
163
+ /** Prefer a friendly name; otherwise a compact middle-ellipsis of the id. */
164
+ export function connectionIdentityLabel(
165
+ name: string | undefined,
166
+ id: string,
167
+ ): { text: string; isId: boolean; title: string } {
168
+ const trimmed = name?.trim();
169
+ if (trimmed) {
170
+ return { text: trimmed, isId: false, title: trimmed };
171
+ }
172
+ return { text: middleEllipsis(id), isId: true, title: id };
173
+ }
174
+
175
+ function IdentityPart({ label }: { label: { text: string; isId: boolean; title: string } }) {
176
+ return (
177
+ <span
178
+ className={`text-truncate ${label.isId ? "text-muted" : ""}`}
179
+ style={{
180
+ minWidth: 0,
181
+ ...(label.isId ? { fontSize: "0.7rem" } : undefined),
182
+ }}
183
+ title={label.title}
184
+ >
185
+ {label.text}
186
+ </span>
187
+ );
188
+ }
189
+
190
+ function ConnectedDeviceRow({ connection }: { connection: INetworkConnectionInfo }) {
191
+ const user = connectionIdentityLabel(connection.userName, connection.userId);
192
+ const device = connectionIdentityLabel(connection.deviceName, connection.deviceId);
193
+ const groupCount = connection.sharedGroups.length;
194
+
195
+ return (
196
+ <li
197
+ className="d-flex align-items-baseline gap-1 py-1"
198
+ style={{ listStyle: "none", minWidth: 0 }}
199
+ >
200
+ <IdentityPart label={user} />
201
+ <span className="text-muted flex-shrink-0" aria-hidden="true">
202
+ ·
203
+ </span>
204
+ <IdentityPart label={device} />
205
+ {groupCount > 0 && (
206
+ <span
207
+ className="text-muted flex-shrink-0 ms-auto"
208
+ style={{ fontSize: "0.7rem" }}
209
+ title={`${groupCount} ${groupCount === 1 ? "group" : "groups"}`}
210
+ >
211
+ {groupCount}g
212
+ </span>
213
+ )}
214
+ </li>
215
+ );
216
+ }
217
+
218
+ function ConnectedDevicesList({ loadState }: { loadState: ConnectionsLoadState }) {
219
+ if (loadState.status === "idle" || loadState.status === "loading") {
220
+ return <p className="small text-muted mb-2">Loading…</p>;
221
+ }
222
+ if (loadState.status === "error") {
223
+ return <p className="small text-muted mb-2">Couldn't load devices</p>;
224
+ }
225
+ if (loadState.connections.length === 0) {
226
+ return <p className="small text-muted mb-2">No connected devices</p>;
227
+ }
228
+ return (
229
+ <ul className="small mb-0 ps-0" style={{ maxHeight: "180px", overflowY: "auto" }}>
230
+ {loadState.connections.map((connection) => (
231
+ <ConnectedDeviceRow key={connection.deviceId} connection={connection} />
232
+ ))}
233
+ </ul>
234
+ );
235
+ }
236
+
69
237
  function NetworkStatusDetails({
70
238
  detailsId,
71
239
  status,
72
240
  unavailable,
241
+ connectionsLoadState,
73
242
  onOpenViewer,
74
243
  }: {
75
244
  detailsId: string;
76
245
  status?: INetworkStatusSummary;
77
246
  unavailable: boolean;
247
+ connectionsLoadState: ConnectionsLoadState;
78
248
  onOpenViewer: () => void;
79
249
  }) {
80
250
  return (
@@ -83,7 +253,7 @@ function NetworkStatusDetails({
83
253
  role="dialog"
84
254
  aria-label="Network status details"
85
255
  className="card shadow position-absolute end-0 mt-1"
86
- style={{ top: "100%", width: "280px", zIndex: 1100 }}
256
+ style={{ top: "100%", width: "320px", zIndex: 1100 }}
87
257
  >
88
258
  <div className="card-body p-3">
89
259
  <h6 className="card-title mb-2">Network status</h6>
@@ -101,21 +271,35 @@ function NetworkStatusDetails({
101
271
  <dd className="col-5 text-end">{status.syncingContextCount}</dd>
102
272
  <dt className="col-7">Sync errors</dt>
103
273
  <dd className="col-5 text-end">{status.errorCount}</dd>
274
+ {status.lastSuccessfulSyncAt != null && (
275
+ <>
276
+ <dt className="col-7">Last sync</dt>
277
+ <dd
278
+ className="col-5 text-end text-truncate"
279
+ title={new Date(status.lastSuccessfulSyncAt).toLocaleString()}
280
+ >
281
+ {formatRelativeSyncTime(status.lastSuccessfulSyncAt)}
282
+ </dd>
283
+ </>
284
+ )}
104
285
  </dl>
105
- {status.lastSuccessfulSyncAt && (
106
- <p className="small text-muted mb-2">
107
- Last successful sync: {new Date(status.lastSuccessfulSyncAt).toLocaleString()}
108
- </p>
109
- )}
110
286
  {status.errors.length > 0 && (
111
287
  <div className="alert alert-danger py-2 px-2 small mb-2">
112
288
  {status.errors[0].message}
113
289
  {status.errors.length > 1 && ` (+${status.errors.length - 1} more)`}
114
290
  </div>
115
291
  )}
292
+ <div className="border-top pt-2 mt-1 mb-2">
293
+ <div className="small text-muted mb-1">Devices</div>
294
+ <ConnectedDevicesList loadState={connectionsLoadState} />
295
+ </div>
116
296
  </>
117
297
  )}
118
- <button type="button" className="btn btn-sm btn-primary w-100" onClick={onOpenViewer}>
298
+ <button
299
+ type="button"
300
+ className="btn btn-sm btn-outline-primary w-100"
301
+ onClick={onOpenViewer}
302
+ >
119
303
  Open Network Viewer
120
304
  </button>
121
305
  </div>
@@ -126,11 +310,83 @@ function NetworkStatusDetails({
126
310
  /** Accessible reactive network status button and details popover. */
127
311
  export function NetworkStatusIndicator({ compact = false }: { compact?: boolean }) {
128
312
  const viewState = useNetworkStatus();
129
- const presentation = getNetworkStatusPresentation(viewState);
313
+ const [displaySyncing, setDisplaySyncing] = useState(false);
314
+ const [syncingLongerThanGreenMs, setSyncingLongerThanGreenMs] = useState(false);
130
315
  const [open, setOpen] = useState(false);
316
+ const [connectionsLoadState, setConnectionsLoadState] = useState<ConnectionsLoadState>({
317
+ status: "idle",
318
+ });
131
319
  const containerRef = useRef<HTMLDivElement>(null);
132
320
  const detailsId = useId();
133
321
 
322
+ const rawState = viewState.availability === "ready" ? viewState.status?.state : undefined;
323
+ const rawIsSyncing = rawState === "syncing";
324
+ const rawIsSynced = rawState === "synced";
325
+ const connectionCount = viewState.status?.connectionCount;
326
+ const diagnosticsReady = viewState.availability === "ready";
327
+
328
+ useEffect(() => {
329
+ if (rawIsSyncing) {
330
+ setDisplaySyncing(true);
331
+ return;
332
+ }
333
+ if (rawIsSynced) {
334
+ if (!displaySyncing) {
335
+ return;
336
+ }
337
+ const timer = window.setTimeout(() => {
338
+ setDisplaySyncing(false);
339
+ }, SYNCED_STABLE_MS);
340
+ return () => {
341
+ window.clearTimeout(timer);
342
+ };
343
+ }
344
+ setDisplaySyncing(false);
345
+ }, [rawIsSyncing, rawIsSynced, displaySyncing]);
346
+
347
+ useEffect(() => {
348
+ if (!rawIsSyncing) {
349
+ setSyncingLongerThanGreenMs(false);
350
+ return;
351
+ }
352
+ const timer = window.setTimeout(() => {
353
+ setSyncingLongerThanGreenMs(true);
354
+ }, SYNCING_GREEN_MS);
355
+ return () => {
356
+ window.clearTimeout(timer);
357
+ };
358
+ }, [rawIsSyncing]);
359
+
360
+ useEffect(() => {
361
+ if (!open || !diagnosticsReady) {
362
+ setConnectionsLoadState({ status: "idle" });
363
+ return;
364
+ }
365
+ let active = true;
366
+ setConnectionsLoadState({ status: "loading" });
367
+ void getNetworkDiagnosticsConsumer()
368
+ .then((diagnostics) => diagnostics.tools.getConnections())
369
+ .then((result) => {
370
+ if (active) {
371
+ setConnectionsLoadState({ status: "ready", connections: result.connections });
372
+ }
373
+ })
374
+ .catch(() => {
375
+ if (active) {
376
+ setConnectionsLoadState({ status: "error" });
377
+ }
378
+ });
379
+ return () => {
380
+ active = false;
381
+ };
382
+ }, [open, diagnosticsReady, connectionCount]);
383
+
384
+ const holdSyncingVisual = displaySyncing && !rawIsSyncing;
385
+ const presentation = getNetworkStatusPresentation(viewState, {
386
+ syncingLongerThanGreenMs,
387
+ holdSyncingVisual,
388
+ });
389
+
134
390
  useEffect(() => {
135
391
  if (!open) {
136
392
  return;
@@ -169,10 +425,19 @@ export function NetworkStatusIndicator({ compact = false }: { compact?: boolean
169
425
  title={presentation.label}
170
426
  onClick={() => setOpen((current) => !current)}
171
427
  >
172
- <i
173
- className={`bi ${presentation.iconClass} ${presentation.spinning ? "spin" : ""}`}
174
- aria-hidden="true"
175
- />
428
+ {presentation.mark ? (
429
+ <StatusMark mode={presentation.mark} colorClass={presentation.colorClass} />
430
+ ) : (
431
+ <i
432
+ className={`bi ${presentation.iconClass} ${presentation.spinning ? "spin" : ""}`}
433
+ style={
434
+ presentation.compactIcon
435
+ ? { fontSize: DOT_SIZE, lineHeight: 1, display: "inline-block" }
436
+ : undefined
437
+ }
438
+ aria-hidden="true"
439
+ />
440
+ )}
176
441
  {!compact && (
177
442
  <span className="small">
178
443
  {viewState.status?.connectionCount ?? 0}
@@ -185,6 +450,7 @@ export function NetworkStatusIndicator({ compact = false }: { compact?: boolean
185
450
  detailsId={detailsId}
186
451
  status={viewState.status}
187
452
  unavailable={viewState.availability !== "ready"}
453
+ connectionsLoadState={connectionsLoadState}
188
454
  onOpenViewer={() => {
189
455
  setOpen(false);
190
456
  goToTabPath("network-viewer");