@peers-app/peers-ui 0.21.2 → 0.21.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 (31) hide show
  1. package/dist/screens/network-viewer/connection-troubleshooter.js +1 -1
  2. package/dist/screens/network-viewer/device-details-modal.js +11 -15
  3. package/dist/screens/network-viewer/group-details-modal.js +60 -17
  4. package/dist/screens/network-viewer/network-viewer-ipc.d.ts +9 -39
  5. package/dist/screens/network-viewer/network-viewer-ipc.js +71 -3
  6. package/dist/screens/network-viewer/network-viewer-ipc.test.d.ts +1 -0
  7. package/dist/screens/network-viewer/network-viewer-ipc.test.js +59 -0
  8. package/dist/screens/network-viewer/network-viewer.js +46 -60
  9. package/dist/screens/network-viewer/remote-logs-panel.js +11 -12
  10. package/dist/tabs-layout/network-status-indicator.d.ts +41 -0
  11. package/dist/tabs-layout/network-status-indicator.js +261 -0
  12. package/dist/tabs-layout/network-status-indicator.test.d.ts +1 -0
  13. package/dist/tabs-layout/network-status-indicator.test.js +333 -0
  14. package/dist/tabs-layout/tabs-layout.js +25 -24
  15. package/dist/tabs-layout/use-network-status.d.ts +8 -0
  16. package/dist/tabs-layout/use-network-status.js +57 -0
  17. package/dist/tabs-layout/use-network-status.test.d.ts +1 -0
  18. package/dist/tabs-layout/use-network-status.test.js +42 -0
  19. package/package.json +3 -3
  20. package/src/screens/network-viewer/connection-troubleshooter.tsx +2 -2
  21. package/src/screens/network-viewer/device-details-modal.tsx +19 -61
  22. package/src/screens/network-viewer/group-details-modal.tsx +211 -130
  23. package/src/screens/network-viewer/network-viewer-ipc.test.ts +70 -0
  24. package/src/screens/network-viewer/network-viewer-ipc.ts +102 -35
  25. package/src/screens/network-viewer/network-viewer.tsx +76 -148
  26. package/src/screens/network-viewer/remote-logs-panel.tsx +14 -16
  27. package/src/tabs-layout/network-status-indicator.test.tsx +391 -0
  28. package/src/tabs-layout/network-status-indicator.tsx +475 -0
  29. package/src/tabs-layout/tabs-layout.tsx +4 -1
  30. package/src/tabs-layout/use-network-status.test.tsx +55 -0
  31. package/src/tabs-layout/use-network-status.ts +68 -0
@@ -0,0 +1,333 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const jsx_runtime_1 = require("react/jsx-runtime");
7
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
8
+ const react_1 = require("@testing-library/react");
9
+ const moment_1 = __importDefault(require("moment"));
10
+ const network_status_indicator_1 = require("./network-status-indicator");
11
+ const goToTabPath = jest.fn();
12
+ const getConnections = jest.fn();
13
+ let mockViewState = { availability: "loading" };
14
+ jest.mock("./tabs-state", () => ({
15
+ goToTabPath: (...args) => goToTabPath(...args),
16
+ }));
17
+ jest.mock("./use-network-status", () => ({
18
+ useNetworkStatus: () => mockViewState,
19
+ }));
20
+ jest.mock("../screens/network-viewer/network-viewer-ipc", () => ({
21
+ getNetworkDiagnosticsConsumer: () => Promise.resolve({
22
+ tools: {
23
+ getConnections: (...args) => getConnections(...args),
24
+ },
25
+ }),
26
+ }));
27
+ function status(overrides) {
28
+ return {
29
+ state: "synced",
30
+ connectionCount: 2,
31
+ syncingContextCount: 0,
32
+ errorCount: 0,
33
+ errors: [],
34
+ ...overrides,
35
+ };
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
+ });
136
+ describe("NetworkStatusIndicator", () => {
137
+ beforeEach(() => {
138
+ mockViewState = { availability: "loading" };
139
+ getConnections.mockReset();
140
+ getConnections.mockResolvedValue({ connections: [] });
141
+ jest.clearAllMocks();
142
+ });
143
+ afterEach(() => {
144
+ jest.useRealTimers();
145
+ });
146
+ it.each([
147
+ [{ availability: "loading" }, /Network status loading/],
148
+ [{ availability: "unavailable" }, /Network status unavailable/],
149
+ [
150
+ { availability: "ready", status: status({ state: "offline", connectionCount: 0 }) },
151
+ /Network offline/,
152
+ ],
153
+ [
154
+ {
155
+ availability: "ready",
156
+ status: status({ state: "syncing", syncingContextCount: 1 }),
157
+ },
158
+ /Network syncing/,
159
+ ],
160
+ [{ availability: "ready", status: status({ state: "synced" }) }, /Network synced/],
161
+ [
162
+ {
163
+ availability: "ready",
164
+ status: status({
165
+ state: "error",
166
+ errorCount: 1,
167
+ errors: [{ dataContextId: "context", message: "failed", occurredAt: 1 }],
168
+ }),
169
+ },
170
+ /Network error/,
171
+ ],
172
+ ])("renders an accessible state for %#", (viewState, accessibleName) => {
173
+ mockViewState = viewState;
174
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
175
+ expect(view.getByRole("button", { name: accessibleName })).toBeTruthy();
176
+ });
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 () => {
310
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
311
+ expect(view.getByRole("button", { name: /loading/ })).toBeTruthy();
312
+ mockViewState = {
313
+ availability: "ready",
314
+ status: status({
315
+ state: "error",
316
+ connectionCount: 3,
317
+ errorCount: 1,
318
+ errors: [{ dataContextId: "context", message: "sync failed", occurredAt: 1 }],
319
+ }),
320
+ };
321
+ view.rerender((0, jsx_runtime_1.jsx)(network_status_indicator_1.NetworkStatusIndicator, {}));
322
+ react_1.fireEvent.click(view.getByRole("button", { name: /Network error/ }));
323
+ expect(view.getByRole("dialog", { name: "Network status details" })).toHaveTextContent("sync failed");
324
+ await (0, react_1.waitFor)(() => {
325
+ expect(getConnections).toHaveBeenCalled();
326
+ });
327
+ react_1.fireEvent.keyDown(document, { key: "Escape" });
328
+ expect(view.queryByRole("dialog", { name: "Network status details" })).toBeNull();
329
+ react_1.fireEvent.click(view.getByRole("button", { name: /Network error/ }));
330
+ react_1.fireEvent.click(view.getByRole("button", { name: "Open Network Viewer" }));
331
+ expect(goToTabPath).toHaveBeenCalledWith("network-viewer");
332
+ });
333
+ });
@@ -48,6 +48,7 @@ const color_mode_dropdown_1 = require("../screens/settings/color-mode-dropdown")
48
48
  const system_apps_1 = require("../system-apps");
49
49
  const routes_loader_1 = require("../ui-router/routes-loader");
50
50
  const ui_loader_1 = require("../ui-router/ui-loader");
51
+ const network_status_indicator_1 = require("./network-status-indicator");
51
52
  const tabs_state_1 = require("./tabs-state");
52
53
  /**
53
54
  * Wraps a single tab's content so a crash in one tab doesn't take down the whole app.
@@ -167,7 +168,7 @@ function TabsLayoutInternal({ fillContainer = false } = {}) {
167
168
  }, children: (0, jsx_runtime_1.jsx)("i", { className: "bi-search", style: { fontSize: "14px" } }) }), (0, jsx_runtime_1.jsx)("div", { className: "d-flex flex-grow-1 overflow-auto", style: {
168
169
  scrollbarWidth: "none",
169
170
  msOverflowStyle: "none",
170
- }, children: tabs.map((tab) => ((0, jsx_runtime_1.jsx)(TabHeader, { tab: tab, isActive: activeTab === tab.tabId, onSwitch: switchTab, onClose: closeTab, colorMode: _colorMode }, tab.tabId))) })] })) }), (0, jsx_runtime_1.jsx)("div", { className: "flex-grow-1 overflow-hidden", children: tabs.map((tab) => ((0, jsx_runtime_1.jsx)("div", { className: `h-100 ${activeTab === tab.tabId ? "d-block" : "d-none"}`, style: { display: activeTab === tab.tabId ? "block" : "none" }, children: (0, jsx_runtime_1.jsx)(TabErrorBoundary, { tabTitle: tab.title, tabId: tab.tabId, children: (0, jsx_runtime_1.jsx)(TabContent, { tab: tab, isMobile: isMobile, isActive: activeTab === tab.tabId }) }) }, tab.tabId))) })] }, currentlyActiveGroupId));
171
+ }, children: tabs.map((tab) => ((0, jsx_runtime_1.jsx)(TabHeader, { tab: tab, isActive: activeTab === tab.tabId, onSwitch: switchTab, onClose: closeTab, colorMode: _colorMode }, tab.tabId))) }), (0, jsx_runtime_1.jsx)(network_status_indicator_1.TabsStripIndicators, {})] })) }), (0, jsx_runtime_1.jsx)("div", { className: "flex-grow-1 overflow-hidden", children: tabs.map((tab) => ((0, jsx_runtime_1.jsx)("div", { className: `h-100 ${activeTab === tab.tabId ? "d-block" : "d-none"}`, style: { display: activeTab === tab.tabId ? "block" : "none" }, children: (0, jsx_runtime_1.jsx)(TabErrorBoundary, { tabTitle: tab.title, tabId: tab.tabId, children: (0, jsx_runtime_1.jsx)(TabContent, { tab: tab, isMobile: isMobile, isActive: activeTab === tab.tabId }) }) }, tab.tabId))) })] }, currentlyActiveGroupId));
171
172
  }
172
173
  function MobileTabsHeader({ tabs, activeTab, onSwitch, onClose, colorMode, }) {
173
174
  const [showMenuDropdown, setShowMenuDropdown] = react_1.default.useState(false);
@@ -178,29 +179,29 @@ function MobileTabsHeader({ tabs, activeTab, onSwitch, onClose, colorMode, }) {
178
179
  transform: "translateX(-50%)",
179
180
  pointerEvents: "none",
180
181
  maxWidth: "calc(100% - 140px)",
181
- }, children: [currentTab?.iconClassName && currentTab.packageId !== "launcher" && ((0, jsx_runtime_1.jsx)("i", { className: `${currentTab.iconClassName} me-2` })), (0, jsx_runtime_1.jsx)("span", { className: "fw-medium text-truncate small", children: currentTab?.title || "Apps" })] }), (0, jsx_runtime_1.jsx)("div", { className: "d-flex align-items-center", children: (0, jsx_runtime_1.jsxs)("div", { className: "dropdown", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: `btn btn-sm ${colorMode === "light" ? "btn-outline-dark" : "btn-outline-light"}`, onClick: () => setShowMenuDropdown(!showMenuDropdown), style: { minWidth: "36px" }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-list" }), nonLauncherTabs.length > 0 && (0, jsx_runtime_1.jsx)("span", { className: "ms-1", children: nonLauncherTabs.length })] }), showMenuDropdown && ((0, jsx_runtime_1.jsxs)("div", { className: `dropdown-menu show position-absolute ${colorMode === "light" ? "" : "dropdown-menu-dark"}`, style: {
182
- right: 0,
183
- top: "100%",
184
- zIndex: 1000,
185
- minWidth: "280px",
186
- maxHeight: "calc(100vh - 60px)",
187
- overflowY: "auto",
188
- }, children: [(0, jsx_runtime_1.jsxs)("div", { className: `dropdown-item d-flex align-items-center ${activeTab === "launcher" ? "active" : ""}`, style: { cursor: "pointer", padding: "14px 18px", fontSize: "17px" }, onClick: () => {
189
- onSwitch("launcher");
190
- setShowMenuDropdown(false);
191
- }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-grid-3x3-gap me-3", style: { fontSize: "20px" } }), (0, jsx_runtime_1.jsx)("span", { children: "Apps" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dropdown-item d-flex align-items-center", style: { cursor: "pointer", padding: "14px 18px", fontSize: "17px" }, onClick: () => {
192
- (0, command_palette_1.openCommandPalette)();
193
- setShowMenuDropdown(false);
194
- }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-search me-3", style: { fontSize: "20px" } }), (0, jsx_runtime_1.jsx)("span", { children: "Search" })] }), nonLauncherTabs.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "dropdown-divider", style: { margin: "8px 0" } })), nonLauncherTabs
195
- .slice()
196
- .reverse()
197
- .map((tab) => ((0, jsx_runtime_1.jsxs)("div", { className: `dropdown-item d-flex align-items-center justify-content-between ${activeTab === tab.tabId ? "active" : ""}`, style: { cursor: "pointer", padding: "14px 18px", fontSize: "17px" }, onClick: () => {
198
- onSwitch(tab.tabId);
199
- setShowMenuDropdown(false);
200
- }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center", children: [tab.iconClassName && ((0, jsx_runtime_1.jsx)("i", { className: `${tab.iconClassName} me-3`, style: { fontSize: "20px" } })), (0, jsx_runtime_1.jsx)("span", { children: tab.title })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm p-0 ms-3", style: { width: "32px", height: "32px", fontSize: "20px" }, onClick: (e) => {
201
- e.stopPropagation();
202
- onClose(tab.tabId);
203
- }, children: (0, jsx_runtime_1.jsx)("i", { className: "bi-x" }) })] }, tab.tabId)))] }))] }) }), showMenuDropdown && ((0, jsx_runtime_1.jsx)("div", { className: "position-fixed w-100 h-100", style: { top: 0, left: 0, zIndex: 999 }, onClick: () => setShowMenuDropdown(false) }))] }));
182
+ }, children: [currentTab?.iconClassName && currentTab.packageId !== "launcher" && ((0, jsx_runtime_1.jsx)("i", { className: `${currentTab.iconClassName} me-2` })), (0, jsx_runtime_1.jsx)("span", { className: "fw-medium text-truncate small", children: currentTab?.title || "Apps" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-1", children: [(0, jsx_runtime_1.jsx)(network_status_indicator_1.TabsStripIndicators, { compact: true }), (0, jsx_runtime_1.jsxs)("div", { className: "dropdown", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: `btn btn-sm ${colorMode === "light" ? "btn-outline-dark" : "btn-outline-light"}`, onClick: () => setShowMenuDropdown(!showMenuDropdown), style: { minWidth: "36px" }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-list" }), nonLauncherTabs.length > 0 && (0, jsx_runtime_1.jsx)("span", { className: "ms-1", children: nonLauncherTabs.length })] }), showMenuDropdown && ((0, jsx_runtime_1.jsxs)("div", { className: `dropdown-menu show position-absolute ${colorMode === "light" ? "" : "dropdown-menu-dark"}`, style: {
183
+ right: 0,
184
+ top: "100%",
185
+ zIndex: 1000,
186
+ minWidth: "280px",
187
+ maxHeight: "calc(100vh - 60px)",
188
+ overflowY: "auto",
189
+ }, children: [(0, jsx_runtime_1.jsxs)("div", { className: `dropdown-item d-flex align-items-center ${activeTab === "launcher" ? "active" : ""}`, style: { cursor: "pointer", padding: "14px 18px", fontSize: "17px" }, onClick: () => {
190
+ onSwitch("launcher");
191
+ setShowMenuDropdown(false);
192
+ }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-grid-3x3-gap me-3", style: { fontSize: "20px" } }), (0, jsx_runtime_1.jsx)("span", { children: "Apps" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dropdown-item d-flex align-items-center", style: { cursor: "pointer", padding: "14px 18px", fontSize: "17px" }, onClick: () => {
193
+ (0, command_palette_1.openCommandPalette)();
194
+ setShowMenuDropdown(false);
195
+ }, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-search me-3", style: { fontSize: "20px" } }), (0, jsx_runtime_1.jsx)("span", { children: "Search" })] }), nonLauncherTabs.length > 0 && ((0, jsx_runtime_1.jsx)("div", { className: "dropdown-divider", style: { margin: "8px 0" } })), nonLauncherTabs
196
+ .slice()
197
+ .reverse()
198
+ .map((tab) => ((0, jsx_runtime_1.jsxs)("div", { className: `dropdown-item d-flex align-items-center justify-content-between ${activeTab === tab.tabId ? "active" : ""}`, style: { cursor: "pointer", padding: "14px 18px", fontSize: "17px" }, onClick: () => {
199
+ onSwitch(tab.tabId);
200
+ setShowMenuDropdown(false);
201
+ }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center", children: [tab.iconClassName && ((0, jsx_runtime_1.jsx)("i", { className: `${tab.iconClassName} me-3`, style: { fontSize: "20px" } })), (0, jsx_runtime_1.jsx)("span", { children: tab.title })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-sm p-0 ms-3", style: { width: "32px", height: "32px", fontSize: "20px" }, onClick: (e) => {
202
+ e.stopPropagation();
203
+ onClose(tab.tabId);
204
+ }, children: (0, jsx_runtime_1.jsx)("i", { className: "bi-x" }) })] }, tab.tabId)))] }))] })] }), showMenuDropdown && ((0, jsx_runtime_1.jsx)("div", { className: "position-fixed w-100 h-100", style: { top: 0, left: 0, zIndex: 999 }, onClick: () => setShowMenuDropdown(false) }))] }));
204
205
  }
205
206
  function TabHeader({ tab, isActive, onSwitch, onClose, colorMode }) {
206
207
  const activeClass = isActive
@@ -0,0 +1,8 @@
1
+ import { type INetworkStatusSummary } from "@peers-app/peers-sdk";
2
+ /** Host availability plus the latest reactive network status snapshot. */
3
+ export interface INetworkStatusViewState {
4
+ availability: "loading" | "ready" | "unavailable";
5
+ status?: INetworkStatusSummary;
6
+ }
7
+ /** Subscribe to the current host's System Network Diagnostics status observable. */
8
+ export declare function useNetworkStatus(): INetworkStatusViewState;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useNetworkStatus = useNetworkStatus;
4
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
+ const react_1 = require("react");
6
+ const network_viewer_ipc_1 = require("../screens/network-viewer/network-viewer-ipc");
7
+ /** Subscribe to the current host's System Network Diagnostics status observable. */
8
+ function useNetworkStatus() {
9
+ const [viewState, setViewState] = (0, react_1.useState)({
10
+ availability: "loading",
11
+ });
12
+ (0, react_1.useEffect)(() => {
13
+ let active = true;
14
+ let generation = 0;
15
+ let statusSubscription;
16
+ let hadTransport = false;
17
+ const unsubscribeTransport = (0, peers_sdk_1.subscribeHostContractTransport)((transport) => {
18
+ generation += 1;
19
+ const currentGeneration = generation;
20
+ statusSubscription?.dispose();
21
+ statusSubscription = undefined;
22
+ if (!transport) {
23
+ setViewState({ availability: hadTransport ? "unavailable" : "loading" });
24
+ return;
25
+ }
26
+ hadTransport = true;
27
+ setViewState({ availability: "loading" });
28
+ void (0, network_viewer_ipc_1.getNetworkDiagnosticsConsumer)()
29
+ .then((diagnostics) => {
30
+ if (!active || generation !== currentGeneration) {
31
+ return;
32
+ }
33
+ setViewState({
34
+ availability: "ready",
35
+ status: diagnostics.observables.status(),
36
+ });
37
+ statusSubscription = diagnostics.observables.status.subscribe((status) => {
38
+ if (active && generation === currentGeneration) {
39
+ setViewState({ availability: "ready", status });
40
+ }
41
+ });
42
+ })
43
+ .catch(() => {
44
+ if (active && generation === currentGeneration) {
45
+ setViewState({ availability: "unavailable" });
46
+ }
47
+ });
48
+ });
49
+ return () => {
50
+ active = false;
51
+ generation += 1;
52
+ statusSubscription?.dispose();
53
+ unsubscribeTransport();
54
+ };
55
+ }, []);
56
+ return viewState;
57
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
4
+ const react_1 = require("@testing-library/react");
5
+ const network_viewer_ipc_1 = require("../screens/network-viewer/network-viewer-ipc");
6
+ const use_network_status_1 = require("./use-network-status");
7
+ jest.mock("../screens/network-viewer/network-viewer-ipc", () => ({
8
+ getNetworkDiagnosticsConsumer: jest.fn(),
9
+ }));
10
+ const mockGetDiagnostics = network_viewer_ipc_1.getNetworkDiagnosticsConsumer;
11
+ function makeStatus(overrides = {}) {
12
+ return {
13
+ state: "offline",
14
+ connectionCount: 0,
15
+ syncingContextCount: 0,
16
+ errorCount: 0,
17
+ errors: [],
18
+ ...overrides,
19
+ };
20
+ }
21
+ describe("useNetworkStatus", () => {
22
+ it("streams status updates and reports host disconnection", async () => {
23
+ const status = (0, peers_sdk_1.observable)(makeStatus());
24
+ mockGetDiagnostics.mockResolvedValue({
25
+ observables: { status },
26
+ });
27
+ const [transport] = (0, peers_sdk_1.inProcessTransportPair)();
28
+ const disposeTransport = (0, peers_sdk_1.registerHostContractTransport)(transport);
29
+ const hook = (0, react_1.renderHook)(() => (0, use_network_status_1.useNetworkStatus)());
30
+ await (0, react_1.waitFor)(() => expect(hook.result.current.availability).toBe("ready"));
31
+ expect(hook.result.current.status?.state).toBe("offline");
32
+ (0, react_1.act)(() => {
33
+ status(makeStatus({ state: "syncing", connectionCount: 2, syncingContextCount: 1 }));
34
+ });
35
+ await (0, react_1.waitFor)(() => expect(hook.result.current.status?.state).toBe("syncing"));
36
+ (0, react_1.act)(() => {
37
+ disposeTransport();
38
+ });
39
+ await (0, react_1.waitFor)(() => expect(hook.result.current.availability).toBe("unavailable"));
40
+ hook.unmount();
41
+ });
42
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
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.2",
31
+ "@peers-app/peers-sdk": "^0.21.4",
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.2",
42
+ "@peers-app/peers-sdk": "0.21.4",
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,6 +1,6 @@
1
1
  import type React from "react";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
- import { getNetworkViewerApi } from "./network-viewer-ipc";
3
+ import { getNetworkViewerPlatformApi } from "./network-viewer-ipc";
4
4
 
5
5
  export interface IDiagnosticCheck {
6
6
  id: string;
@@ -106,7 +106,7 @@ export function ConnectionTroubleshooter({ onBack }: Props) {
106
106
  setChecks([]);
107
107
  setExpanded(new Set());
108
108
 
109
- const api = getNetworkViewerApi();
109
+ const api = getNetworkViewerPlatformApi();
110
110
  if (!api) {
111
111
  setChecks([
112
112
  {