@peers-app/peers-ui 0.25.1 → 0.25.3

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 (177) hide show
  1. package/README.md +2 -1
  2. package/dist/components/markdown-editor/mention-node.d.ts +4 -0
  3. package/dist/components/markdown-editor/mention-node.js +4 -1
  4. package/dist/components/responsive-section-nav.d.ts +34 -0
  5. package/dist/components/responsive-section-nav.js +19 -0
  6. package/dist/components/responsive-section-nav.test.d.ts +1 -0
  7. package/dist/components/responsive-section-nav.test.js +45 -0
  8. package/dist/components/router.d.ts +2 -3
  9. package/dist/components/router.js +6 -9
  10. package/dist/globals-hash.test.d.ts +1 -0
  11. package/dist/globals-hash.test.js +16 -0
  12. package/dist/globals.d.ts +19 -0
  13. package/dist/globals.js +51 -12
  14. package/dist/hooks/use-host-contract.d.ts +18 -0
  15. package/dist/hooks/use-host-contract.js +53 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/peers-services-registration.d.ts +1 -5
  19. package/dist/peers-services-registration.js +6 -16
  20. package/dist/root-layout/root-layout-host.js +7 -4
  21. package/dist/root-layout/root-layout-host.test.js +3 -6
  22. package/dist/screens/account/account-screen.d.ts +1 -0
  23. package/dist/screens/account/account-screen.js +29 -53
  24. package/dist/screens/account/account-screen.test.d.ts +1 -0
  25. package/dist/screens/account/account-screen.test.js +47 -0
  26. package/dist/screens/contacts/contact-details.js +2 -23
  27. package/dist/screens/contacts/contact-list.d.ts +24 -1
  28. package/dist/screens/contacts/contact-list.js +128 -143
  29. package/dist/screens/contacts/contact-list.test.d.ts +1 -0
  30. package/dist/screens/contacts/contact-list.test.js +48 -0
  31. package/dist/screens/contacts/index.d.ts +0 -2
  32. package/dist/screens/contacts/index.js +0 -3
  33. package/dist/screens/device-pairing/device-pairing.js +17 -16
  34. package/dist/screens/device-pairing/device-pairing.test.js +4 -9
  35. package/dist/screens/device-pairing/identity-ipc.d.ts +11 -0
  36. package/dist/screens/device-pairing/identity-ipc.js +20 -0
  37. package/dist/screens/device-pairing/use-device-pairing-state.d.ts +2 -2
  38. package/dist/screens/device-pairing/use-device-pairing-state.js +7 -25
  39. package/dist/screens/devices/devices-list.d.ts +18 -0
  40. package/dist/screens/devices/devices-list.js +173 -0
  41. package/dist/screens/devices/devices-list.test.d.ts +1 -0
  42. package/dist/screens/devices/devices-list.test.js +53 -0
  43. package/dist/screens/devices/index.d.ts +1 -0
  44. package/dist/screens/devices/index.js +5 -0
  45. package/dist/screens/groups/group-details.d.ts +1 -0
  46. package/dist/screens/groups/group-details.js +4 -63
  47. package/dist/screens/groups/group-invite-panel.js +2 -2
  48. package/dist/screens/groups/group-list.d.ts +18 -1
  49. package/dist/screens/groups/group-list.js +107 -136
  50. package/dist/screens/groups/group-list.test.d.ts +1 -0
  51. package/dist/screens/groups/group-list.test.js +38 -0
  52. package/dist/screens/groups/group-members.d.ts +1 -0
  53. package/dist/screens/groups/group-members.js +2 -1
  54. package/dist/screens/groups/index.d.ts +0 -3
  55. package/dist/screens/groups/index.js +0 -4
  56. package/dist/screens/identity/identity-routes.d.ts +107 -0
  57. package/dist/screens/identity/identity-routes.js +245 -0
  58. package/dist/screens/identity/identity-routes.test.d.ts +1 -0
  59. package/dist/screens/identity/identity-routes.test.js +51 -0
  60. package/dist/screens/identity/identity-screen.d.ts +9 -0
  61. package/dist/screens/identity/identity-screen.js +143 -0
  62. package/dist/screens/identity/identity-screen.test.d.ts +1 -0
  63. package/dist/screens/identity/identity-screen.test.js +103 -0
  64. package/dist/screens/identity/index.d.ts +2 -0
  65. package/dist/screens/identity/index.js +6 -0
  66. package/dist/screens/identity/invite-contact-panel.d.ts +4 -0
  67. package/dist/screens/identity/invite-contact-panel.js +30 -0
  68. package/dist/screens/identity/profile-invite.d.ts +7 -0
  69. package/dist/screens/identity/profile-invite.js +62 -0
  70. package/dist/screens/invites/accept-invite.js +1 -1
  71. package/dist/screens/invites/activity-view.d.ts +24 -0
  72. package/dist/screens/invites/activity-view.js +64 -0
  73. package/dist/screens/invites/activity-view.test.d.ts +1 -0
  74. package/dist/screens/invites/activity-view.test.js +108 -0
  75. package/dist/screens/invites/index.d.ts +1 -2
  76. package/dist/screens/invites/index.js +1 -3
  77. package/dist/screens/invites/invite-capture.d.ts +0 -2
  78. package/dist/screens/invites/invite-capture.js +1 -3
  79. package/dist/screens/invites/invite-classify.test.d.ts +1 -0
  80. package/dist/screens/invites/invite-classify.test.js +98 -0
  81. package/dist/screens/invites/invites-ipc.d.ts +1 -1
  82. package/dist/screens/invites/invites-ipc.js +5 -45
  83. package/dist/screens/invites/pending-invites-list.d.ts +35 -16
  84. package/dist/screens/invites/pending-invites-list.js +69 -30
  85. package/dist/screens/network-viewer/network-viewer-ipc.js +4 -59
  86. package/dist/screens/search/global-search.d.ts +1 -0
  87. package/dist/screens/search/global-search.js +2 -1
  88. package/dist/screens/settings/settings-page.js +25 -58
  89. package/dist/screens/setup-user.js +5 -5
  90. package/dist/screens/tools/tool-access-ipc.d.ts +2 -0
  91. package/dist/screens/tools/tool-access-ipc.js +8 -55
  92. package/dist/screens/welcome-modal.js +3 -2
  93. package/dist/system-apps/identity.app.d.ts +3 -0
  94. package/dist/system-apps/identity.app.js +10 -0
  95. package/dist/system-apps/index.d.ts +1 -4
  96. package/dist/system-apps/index.js +5 -17
  97. package/dist/tabs-layout/network-status-indicator.js +10 -3
  98. package/dist/tabs-layout/network-status-indicator.test.js +15 -0
  99. package/dist/tabs-layout/tabs-layout.js +3 -1
  100. package/dist/tabs-layout/tabs-state.d.ts +79 -1
  101. package/dist/tabs-layout/tabs-state.js +256 -79
  102. package/dist/tabs-layout/tabs-state.test.d.ts +1 -0
  103. package/dist/tabs-layout/tabs-state.test.js +110 -0
  104. package/package.json +3 -3
  105. package/src/components/markdown-editor/mention-node.ts +5 -1
  106. package/src/components/responsive-section-nav.test.tsx +88 -0
  107. package/src/components/responsive-section-nav.tsx +92 -0
  108. package/src/components/router.tsx +6 -9
  109. package/src/globals-hash.test.ts +19 -0
  110. package/src/globals.tsx +53 -13
  111. package/src/hooks/use-host-contract.ts +75 -0
  112. package/src/index.tsx +1 -0
  113. package/src/peers-services-registration.ts +6 -17
  114. package/src/root-layout/root-layout-host.test.tsx +4 -7
  115. package/src/root-layout/root-layout-host.tsx +9 -11
  116. package/src/screens/account/account-screen.test.tsx +61 -0
  117. package/src/screens/account/account-screen.tsx +116 -95
  118. package/src/screens/contacts/contact-details.tsx +4 -26
  119. package/src/screens/contacts/contact-list.test.tsx +59 -0
  120. package/src/screens/contacts/contact-list.tsx +246 -260
  121. package/src/screens/contacts/index.ts +0 -4
  122. package/src/screens/device-pairing/device-pairing.test.tsx +7 -9
  123. package/src/screens/device-pairing/device-pairing.tsx +19 -23
  124. package/src/screens/device-pairing/identity-ipc.ts +25 -0
  125. package/src/screens/device-pairing/use-device-pairing-state.ts +10 -33
  126. package/src/screens/devices/devices-list.test.tsx +68 -0
  127. package/src/screens/devices/devices-list.tsx +361 -0
  128. package/src/screens/devices/index.ts +1 -0
  129. package/src/screens/groups/group-details.tsx +8 -142
  130. package/src/screens/groups/group-invite-panel.tsx +2 -7
  131. package/src/screens/groups/group-list.test.tsx +44 -0
  132. package/src/screens/groups/group-list.tsx +227 -288
  133. package/src/screens/groups/group-members.tsx +3 -2
  134. package/src/screens/groups/index.ts +0 -5
  135. package/src/screens/identity/identity-routes.test.ts +70 -0
  136. package/src/screens/identity/identity-routes.ts +301 -0
  137. package/src/screens/identity/identity-screen.test.tsx +138 -0
  138. package/src/screens/identity/identity-screen.tsx +282 -0
  139. package/src/screens/identity/index.ts +2 -0
  140. package/src/screens/identity/invite-contact-panel.tsx +45 -0
  141. package/src/screens/identity/profile-invite.tsx +93 -0
  142. package/src/screens/invites/accept-invite.tsx +1 -1
  143. package/src/screens/invites/activity-view.test.tsx +127 -0
  144. package/src/screens/invites/activity-view.tsx +162 -0
  145. package/src/screens/invites/index.ts +1 -4
  146. package/src/screens/invites/invite-capture.ts +0 -3
  147. package/src/screens/invites/invite-classify.test.ts +110 -0
  148. package/src/screens/invites/invites-ipc.ts +6 -55
  149. package/src/screens/invites/pending-invites-list.tsx +85 -61
  150. package/src/screens/network-viewer/network-viewer-ipc.ts +8 -82
  151. package/src/screens/search/global-search.tsx +2 -1
  152. package/src/screens/settings/settings-page.tsx +53 -126
  153. package/src/screens/setup-user.tsx +5 -6
  154. package/src/screens/tools/tool-access-ipc.ts +13 -74
  155. package/src/screens/welcome-modal.tsx +3 -3
  156. package/src/system-apps/identity.app.ts +9 -0
  157. package/src/system-apps/index.ts +3 -12
  158. package/src/tabs-layout/network-status-indicator.test.tsx +22 -0
  159. package/src/tabs-layout/network-status-indicator.tsx +31 -0
  160. package/src/tabs-layout/tabs-layout.tsx +7 -1
  161. package/src/tabs-layout/tabs-state.test.ts +152 -0
  162. package/src/tabs-layout/tabs-state.ts +300 -83
  163. package/dist/screens/invites/invites-screen.d.ts +0 -9
  164. package/dist/screens/invites/invites-screen.js +0 -36
  165. package/dist/system-apps/account.app.d.ts +0 -2
  166. package/dist/system-apps/account.app.js +0 -8
  167. package/dist/system-apps/contacts.app.d.ts +0 -2
  168. package/dist/system-apps/contacts.app.js +0 -9
  169. package/dist/system-apps/groups.app.d.ts +0 -2
  170. package/dist/system-apps/groups.app.js +0 -9
  171. package/dist/system-apps/invites.app.d.ts +0 -2
  172. package/dist/system-apps/invites.app.js +0 -9
  173. package/src/screens/invites/invites-screen.tsx +0 -78
  174. package/src/system-apps/account.app.ts +0 -7
  175. package/src/system-apps/contacts.app.ts +0 -8
  176. package/src/system-apps/groups.app.ts +0 -8
  177. package/src/system-apps/invites.app.ts +0 -8
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ jest.mock("../globals", () => {
4
+ const { observable } = jest.requireActual("@peers-app/peers-sdk");
5
+ return { _mainContentPath: observable("") };
6
+ });
7
+ jest.mock("../ui-router/routes-loader", () => {
8
+ const { observable } = jest.requireActual("@peers-app/peers-sdk");
9
+ return { allPackages: observable([]) };
10
+ });
11
+ jest.mock("@peers-app/peers-sdk", () => {
12
+ const actual = jest.requireActual("@peers-app/peers-sdk");
13
+ let nextId = 0;
14
+ const deviceVars = new Map();
15
+ const userVars = new Map();
16
+ function stored(map, id, initial) {
17
+ const existing = map.get(id);
18
+ if (existing) {
19
+ return existing;
20
+ }
21
+ const value = actual.observable(initial);
22
+ Object.assign(value, { loadingPromise: Promise.resolve() });
23
+ map.set(id, value);
24
+ return value;
25
+ }
26
+ return {
27
+ ...actual,
28
+ newid: () => `tab${++nextId}`,
29
+ groupDeviceVar: (id, options) => stored(deviceVars, id, options?.defaultValue),
30
+ groupUserVar: (id, options) => stored(userVars, id, options?.defaultValue),
31
+ };
32
+ });
33
+ const globals_1 = require("../globals");
34
+ const tabs_state_1 = require("./tabs-state");
35
+ const PERSON_ID = "abcdefghijklmnopqrstuvwxy";
36
+ function tab(partial) {
37
+ return {
38
+ packageId: "system-apps",
39
+ title: partial.path,
40
+ iconClassName: "bi-circle",
41
+ ...partial,
42
+ };
43
+ }
44
+ describe("restoreNavigationState", () => {
45
+ it("merges legacy identity tabs into the active Identity tab", () => {
46
+ const restored = (0, tabs_state_1.restoreNavigationState)([
47
+ tabs_state_1.launcherApp,
48
+ tab({ tabId: "people", path: "contacts", title: "Contacts" }),
49
+ tab({ tabId: "groups", path: `groups/${PERSON_ID}`, title: "Groups" }),
50
+ tab({ tabId: "devices", path: "devices?action=pair", title: "Devices" }),
51
+ tab({ tabId: "account", path: "account", title: "Account" }),
52
+ ], "groups");
53
+ expect(restored.tabs.map((entry) => entry.path)).toEqual(["", `identity/groups/${PERSON_ID}`]);
54
+ expect(restored.activeId).toBe("groups");
55
+ expect(restored.tabs[1]?.title).toBe("Identity");
56
+ expect((0, tabs_state_1.migrateRecentPath)("contacts")).toBe("identity");
57
+ expect((0, tabs_state_1.migrateRecentPath)("invites/accept")).toBe("identity");
58
+ expect((0, tabs_state_1.migrateRecentPath)("account")).toBe("identity");
59
+ });
60
+ });
61
+ describe("navigateTo", () => {
62
+ beforeEach(async () => {
63
+ await tabs_state_1.activeTabs.loadingPromise;
64
+ (0, tabs_state_1.activeTabs)([tabs_state_1.launcherApp, tab({ tabId: "threads", path: "threads", title: "Threads" })]);
65
+ (0, tabs_state_1.activeTabId)("threads");
66
+ (0, tabs_state_1.recentlyUsedApps)([]);
67
+ (0, globals_1._mainContentPath)("threads");
68
+ });
69
+ it("updates the same app in place and preserves the query", () => {
70
+ (0, tabs_state_1.navigateTo)("threads/thread-1?draft=1");
71
+ expect((0, tabs_state_1.activeTabs)()).toHaveLength(2);
72
+ expect((0, tabs_state_1.activeTabs)().find((entry) => entry.tabId === "threads")?.path).toBe("threads/thread-1?draft=1");
73
+ expect((0, tabs_state_1.activeTabId)()).toBe("threads");
74
+ expect((0, globals_1._mainContentPath)()).toBe("threads/thread-1?draft=1");
75
+ });
76
+ it("opens another app without duplicating an existing tab", () => {
77
+ (0, tabs_state_1.navigateTo)(`contacts/${PERSON_ID}`);
78
+ const identityId = (0, tabs_state_1.activeTabId)();
79
+ (0, tabs_state_1.activeTabId)("threads");
80
+ (0, tabs_state_1.navigateTo)("identity/devices?foo=bar");
81
+ const identityTabs = (0, tabs_state_1.activeTabs)().filter((entry) => entry.path.startsWith("identity"));
82
+ expect(identityTabs).toHaveLength(1);
83
+ expect(identityTabs[0]?.path).toBe("identity/devices?foo=bar");
84
+ expect((0, tabs_state_1.activeTabId)()).toBe(identityId);
85
+ expect((0, tabs_state_1.activeTabs)().some((entry) => entry.tabId === "threads")).toBe(true);
86
+ });
87
+ it("opens the account alias inside the Identity tab", () => {
88
+ (0, tabs_state_1.navigateTo)("account");
89
+ const identityId = (0, tabs_state_1.activeTabId)();
90
+ expect((0, globals_1._mainContentPath)()).toBe("identity/account");
91
+ (0, tabs_state_1.navigateTo)("identity/people");
92
+ expect((0, tabs_state_1.activeTabId)()).toBe(identityId);
93
+ expect((0, tabs_state_1.activeTabs)().filter((entry) => entry.path.startsWith("identity"))).toHaveLength(1);
94
+ expect((0, globals_1._mainContentPath)()).toBe("identity/people");
95
+ });
96
+ it("rewrites invite acceptance onto the open Identity tab", () => {
97
+ (0, tabs_state_1.navigateTo)("identity/people");
98
+ const identityId = (0, tabs_state_1.activeTabId)();
99
+ (0, tabs_state_1.navigateTo)("invites/accept");
100
+ expect((0, tabs_state_1.activeTabId)()).toBe(identityId);
101
+ expect((0, tabs_state_1.activeTabs)().filter((entry) => entry.path.startsWith("identity"))).toHaveLength(1);
102
+ expect((0, globals_1._mainContentPath)()).toBe("identity/activity/accept");
103
+ });
104
+ it("keeps the current section when the app root is opened again", () => {
105
+ (0, tabs_state_1.navigateTo)("identity/devices/pair");
106
+ (0, tabs_state_1.goToTabPath)("identity");
107
+ expect((0, globals_1._mainContentPath)()).toBe("identity/devices/pair");
108
+ expect((0, tabs_state_1.recentlyUsedApps)()).toEqual(["identity"]);
109
+ });
110
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.25.1",
3
+ "version": "0.25.3",
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.25.1",
31
+ "@peers-app/peers-sdk": "^0.25.3",
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.25.1",
42
+ "@peers-app/peers-sdk": "0.25.3",
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",
@@ -14,6 +14,7 @@ import {
14
14
  import { mainContentPath } from "../../globals";
15
15
  import { mentionConfigs } from "../../mention-configs";
16
16
 
17
+ /** Serialized Lexical payload for a Peers mention node. */
17
18
  export type SerializedMentionNode = Spread<
18
19
  {
19
20
  data: IMentionData;
@@ -43,6 +44,7 @@ function $convertMentionElement(domNode: HTMLElement): DOMConversionOutput | nul
43
44
  return null;
44
45
  }
45
46
 
47
+ /** Lexical text entity that renders and navigates a typed Peers mention. */
46
48
  export class MentionNode extends TextNode {
47
49
  static getType(): string {
48
50
  return "mention";
@@ -109,7 +111,7 @@ export class MentionNode extends TextNode {
109
111
  onClick(this.data);
110
112
  } else {
111
113
  const id = this.data.id;
112
- const href = `#${kind}s/${id}`;
114
+ const href = kind === "user" ? `identity/people/${id}` : `${kind}s/${id}`;
113
115
  mainContentPath(href);
114
116
  }
115
117
  });
@@ -189,12 +191,14 @@ export class MentionNode extends TextNode {
189
191
  }
190
192
  }
191
193
 
194
+ /** Create a segmented Lexical mention node from mention data. */
192
195
  export function $createMentionNode(data: IMentionData): MentionNode {
193
196
  const mentionNode = new MentionNode(data);
194
197
  mentionNode.setMode("segmented").toggleDirectionless();
195
198
  return $applyNodeReplacement(mentionNode);
196
199
  }
197
200
 
201
+ /** Return whether a Lexical node is a {@link MentionNode}. */
198
202
  export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {
199
203
  return node instanceof MentionNode;
200
204
  }
@@ -0,0 +1,88 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { ResponsiveSectionNav } from "./responsive-section-nav";
4
+
5
+ jest.mock("../globals", () => {
6
+ const { observable: createObservable } =
7
+ jest.requireActual<typeof import("@peers-app/peers-sdk")>("@peers-app/peers-sdk");
8
+ return { isDesktop: createObservable(true) };
9
+ });
10
+
11
+ import { isDesktop } from "../globals";
12
+
13
+ const sections = [
14
+ { id: "activity", label: "Activity", iconClassName: "bi-inbox", badge: 2 },
15
+ { id: "people", label: "People", iconClassName: "bi-person" },
16
+ { id: "groups", label: "Groups" },
17
+ { id: "devices", label: "Devices" },
18
+ ];
19
+
20
+ describe("ResponsiveSectionNav", () => {
21
+ beforeEach(() => {
22
+ isDesktop(true);
23
+ });
24
+
25
+ it("uses a vertical sidebar on desktop and marks the selected section", () => {
26
+ render(
27
+ <ResponsiveSectionNav
28
+ label="Identity"
29
+ sections={sections}
30
+ activeSectionId="people"
31
+ onSelect={() => undefined}
32
+ toolbar={<div>Quick actions</div>}
33
+ >
34
+ <div>People body</div>
35
+ </ResponsiveSectionNav>,
36
+ );
37
+
38
+ expect(screen.getByRole("navigation", { name: "Identity" }).querySelector("ul")).toHaveClass(
39
+ "nav-pills",
40
+ "flex-column",
41
+ );
42
+ expect(screen.getByRole("button", { name: /People/ })).toHaveAttribute("aria-current", "page");
43
+ expect(screen.getByText("People body")).toBeInTheDocument();
44
+ expect(screen.getByText("Quick actions")).toBeInTheDocument();
45
+ expect(screen.getByText("Quick actions").closest("[data-section-scroll-container]")).toBeNull();
46
+ expect(screen.getByText("People body").closest("[data-section-scroll-container]")).toHaveClass(
47
+ "overflow-auto",
48
+ );
49
+ });
50
+
51
+ it("uses a non-wrapping horizontal strip on a narrow screen", () => {
52
+ isDesktop(false);
53
+ render(
54
+ <ResponsiveSectionNav
55
+ label="Identity"
56
+ sections={sections}
57
+ activeSectionId="activity"
58
+ onSelect={() => undefined}
59
+ >
60
+ <div>Activity body</div>
61
+ </ResponsiveSectionNav>,
62
+ );
63
+
64
+ expect(screen.getByRole("navigation", { name: "Identity" }).querySelector("ul")).toHaveClass(
65
+ "nav-tabs",
66
+ "flex-nowrap",
67
+ "overflow-x-auto",
68
+ "overflow-y-hidden",
69
+ );
70
+ });
71
+
72
+ it("reports the section chosen from the keyboard-focusable button", async () => {
73
+ const onSelect = jest.fn();
74
+ render(
75
+ <ResponsiveSectionNav
76
+ label="Identity"
77
+ sections={sections}
78
+ activeSectionId="activity"
79
+ onSelect={onSelect}
80
+ >
81
+ <div>Activity body</div>
82
+ </ResponsiveSectionNav>,
83
+ );
84
+
85
+ await userEvent.click(screen.getByRole("button", { name: /Groups/ }));
86
+ expect(onSelect).toHaveBeenCalledWith("groups");
87
+ });
88
+ });
@@ -0,0 +1,92 @@
1
+ import type { ReactNode } from "react";
2
+ import { isDesktop } from "../globals";
3
+ import { useObservable } from "../hooks";
4
+
5
+ /** One destination in {@link ResponsiveSectionNav}. */
6
+ export interface ISectionNavItem {
7
+ /** Stable id passed to {@link ResponsiveSectionNavProps.onSelect}. */
8
+ id: string;
9
+ /** Visible label. */
10
+ label: string;
11
+ /** Bootstrap icon class, without a leading markup wrapper. */
12
+ iconClassName?: string;
13
+ /** Optional count. Hidden when zero or omitted. */
14
+ badge?: number;
15
+ }
16
+
17
+ /** Props for {@link ResponsiveSectionNav}. */
18
+ export interface ResponsiveSectionNavProps {
19
+ /** Accessible name for the section navigation. */
20
+ label: string;
21
+ /** Sections in display order. */
22
+ sections: ISectionNavItem[];
23
+ /** Id of the selected section. */
24
+ activeSectionId: string;
25
+ /** Called when a section button is activated. */
26
+ onSelect: (sectionId: string) => void;
27
+ /** Optional controls that remain visible above the scrolling section body. */
28
+ toolbar?: ReactNode;
29
+ /** Selected section body. */
30
+ children: ReactNode;
31
+ }
32
+
33
+ /**
34
+ * Section navigation that uses a left sidebar on a wide screen and a
35
+ * non-wrapping horizontal strip on a narrow one.
36
+ *
37
+ * The parent owns the route. This component only reports the selected id.
38
+ */
39
+ export function ResponsiveSectionNav(props: ResponsiveSectionNavProps) {
40
+ const [desktop] = useObservable(isDesktop);
41
+ return (
42
+ <div
43
+ className={`d-flex h-100 overflow-hidden ${desktop ? "flex-row" : "flex-column"}`}
44
+ style={{ minHeight: 0, minWidth: 0 }}
45
+ >
46
+ <nav
47
+ aria-label={props.label}
48
+ className={desktop ? "border-end p-3 overflow-auto" : "border-bottom px-2 pt-2 pb-1"}
49
+ style={desktop ? { flex: "0 0 220px", width: 220 } : undefined}
50
+ >
51
+ <ul
52
+ className={`nav ${desktop ? "nav-pills flex-column" : "nav-tabs flex-nowrap overflow-x-auto overflow-y-hidden"}`}
53
+ >
54
+ {props.sections.map((section) => {
55
+ const selected = section.id === props.activeSectionId;
56
+ return (
57
+ <li className="nav-item" key={section.id}>
58
+ <button
59
+ type="button"
60
+ className={`nav-link d-flex align-items-center gap-2 w-100 text-start ${selected ? "active" : ""}`}
61
+ aria-current={selected ? "page" : undefined}
62
+ onClick={() => props.onSelect(section.id)}
63
+ >
64
+ {section.iconClassName ? <i className={section.iconClassName} /> : null}
65
+ <span>{section.label}</span>
66
+ {section.badge ? (
67
+ <span className={`badge ${selected ? "bg-light text-primary" : "bg-primary"}`}>
68
+ {section.badge}
69
+ </span>
70
+ ) : null}
71
+ </button>
72
+ </li>
73
+ );
74
+ })}
75
+ </ul>
76
+ </nav>
77
+ <div
78
+ className="d-flex flex-column flex-grow-1 overflow-hidden"
79
+ style={{ minHeight: 0, minWidth: 0 }}
80
+ >
81
+ {props.toolbar ? <div className="flex-shrink-0">{props.toolbar}</div> : null}
82
+ <div
83
+ className="flex-grow-1 overflow-auto"
84
+ style={{ minHeight: 0, minWidth: 0 }}
85
+ data-section-scroll-container
86
+ >
87
+ {props.children}
88
+ </div>
89
+ </div>
90
+ </div>
91
+ );
92
+ }
@@ -1,8 +1,8 @@
1
1
  import * as globals from "../globals";
2
2
  import { useObservable } from "../hooks";
3
- import { AccountScreen } from "../screens/account/account-screen";
4
3
  import { AssistantDetails } from "../screens/assistants/assistant-details";
5
4
  import { AssistantList } from "../screens/assistants/assistant-list";
5
+ import { canonicalizeRoute } from "../screens/identity/identity-routes";
6
6
  import { PackageDetails } from "../screens/packages/package-details";
7
7
  import { PackageList } from "../screens/packages/package-list";
8
8
  import { PackageNewLocal } from "../screens/packages/package-new-local";
@@ -23,19 +23,19 @@ import { UIRouter } from "../ui-router/ui-loader";
23
23
  import { ChannelMessages } from "./messages/channel-view";
24
24
  import { ThreadMessageList } from "./messages/thread-message-list";
25
25
  import "../screens/console-logs/console-logs-list";
26
- import "../screens/groups";
27
- import "../screens/contacts";
28
- import "../screens/invites";
26
+ import "../screens/identity";
29
27
  import "../screens/network-viewer";
30
28
  import "../screens/data-explorer";
31
29
 
30
+ /** Render the built-in or registered UI that owns a canonicalized route. */
32
31
  export function Router({ path: providedPath }: { path?: string } = {}) {
33
32
  // Hooks must run unconditionally (same order every render)
34
33
  const [globalPath] = useObservable(globals.mainContentPath);
35
34
 
36
35
  try {
37
- // Use provided path or fall back to global mainContentPath
38
- let path = providedPath ?? globalPath;
36
+ // Use provided path or fall back to global mainContentPath.
37
+ // Legacy contacts, groups, devices, invites, and account paths render as Identity.
38
+ let path = canonicalizeRoute(providedPath ?? globalPath);
39
39
  const originalPath = path;
40
40
  path = path.toLowerCase().trim();
41
41
  path = path.split("?")[0]; // remove query string
@@ -55,9 +55,6 @@ export function Router({ path: providedPath }: { path?: string } = {}) {
55
55
  if (path === "settings" || path === "profile") {
56
56
  return <SettingsPage />;
57
57
  }
58
- if (path === "account") {
59
- return <AccountScreen />;
60
- }
61
58
  if (path === "threads" || path === "") {
62
59
  return <ChannelMessages />;
63
60
  }
@@ -0,0 +1,19 @@
1
+ jest.mock("./tabs-layout/tabs-state", () => ({
2
+ handleMainPathChanged: (_oldPath: string, newPath: string, storage: (value: string) => void) =>
3
+ storage(newPath),
4
+ openNewTab: jest.fn(),
5
+ }));
6
+
7
+ import { initializeHashNavigation, mainContentPath } from "./globals";
8
+
9
+ describe("initial hash navigation", () => {
10
+ it("lets an incoming legacy deep link win and canonicalizes it", () => {
11
+ mainContentPath("identity/activity");
12
+ window.history.replaceState(null, "", "/#contacts/abcdefghijklmnopqrstuvwxy?from=link");
13
+
14
+ initializeHashNavigation();
15
+
16
+ expect(mainContentPath()).toBe("identity/people/abcdefghijklmnopqrstuvwxy?from=link");
17
+ expect(window.location.hash).toBe("#identity/people/abcdefghijklmnopqrstuvwxy?from=link");
18
+ });
19
+ });
package/src/globals.tsx CHANGED
@@ -13,13 +13,17 @@ import {
13
13
  rpcServerCalls,
14
14
  sleep,
15
15
  } from "@peers-app/peers-sdk";
16
+ import { canonicalizeRoute } from "./screens/identity/identity-routes";
16
17
  import { handleMainPathChanged, openNewTab } from "./tabs-layout/tabs-state";
17
18
  import { stripMentions } from "./utils";
18
19
 
20
+ /** Counter incremented when installed package UI should reload. */
19
21
  export const packageReloaded = observable(0);
20
22
 
23
+ /** Persisted route for the active data context. Prefer {@link mainContentPath}. */
21
24
  export const _mainContentPath = groupUserVar("mainContentPath", { defaultValue: "" });
22
25
 
26
+ /** Canonical navigation observable backed by the active tab state. */
23
27
  export const mainContentPath = computed<string>({
24
28
  read: () => {
25
29
  return _mainContentPath();
@@ -52,6 +56,7 @@ const queryParams = computed<{ [key: string]: string }>({
52
56
  },
53
57
  });
54
58
 
59
+ /** Read or update one query parameter on {@link mainContentPath}. */
55
60
  export function queryParam(name: string, value?: string) {
56
61
  const params = queryParams();
57
62
  if (value !== undefined) {
@@ -68,10 +73,12 @@ export function queryParam(name: string, value?: string) {
68
73
  return params[name];
69
74
  }
70
75
 
76
+ /** Open a message thread in its owning app tab. */
71
77
  export async function openThread(thread: string | IMessage) {
72
78
  openThreadInTab(thread);
73
79
  }
74
80
 
81
+ /** Resolve a message and open its root thread in a new tab. */
75
82
  export async function openThreadInTab(thread: string | IMessage) {
76
83
  const threadId = typeof thread === "string" ? thread : thread.messageId;
77
84
  const message = await Messages().get(threadId);
@@ -109,24 +116,70 @@ rpcClientCalls.openThread = async (threadId: string) => {
109
116
  openThread(threadId);
110
117
  };
111
118
 
119
+ /** Signed profile for the active user after {@link loadGlobals}. */
112
120
  export let me: IUser = null as unknown as IUser;
113
121
 
122
+ /** Current browser viewport width. */
114
123
  export const windowWidth = observable(window.innerWidth); //.extend({ rateLimit: 500 });
124
+ /** Current browser viewport height. */
115
125
  export const windowHeight = observable(window.innerHeight); //.extend({ rateLimit: 500 });
116
126
  window.addEventListener("resize", (_ev) => {
117
127
  windowWidth(window.innerWidth);
118
128
  windowHeight(window.innerHeight);
119
129
  });
120
130
 
131
+ /** True when the viewport uses wide-screen navigation. */
121
132
  export const isDesktop = computed(() => {
122
133
  // return windowWidth() > 1200;
123
134
  return windowWidth() > 992;
124
135
  // return windowWidth() > 768;
125
136
  });
126
137
 
138
+ /** Enabled groups available to the active user. */
127
139
  export const groups = observable<IGroup[]>([]);
128
140
 
129
141
  let globalsLoaded = false;
142
+ let hashNavigationInitialized = false;
143
+
144
+ /**
145
+ * Start two-way hash synchronization after routes and persisted tabs are restored.
146
+ *
147
+ * A non-empty startup hash wins over persisted navigation so canonical and legacy
148
+ * deep links open the destination the user requested.
149
+ */
150
+ export function initializeHashNavigation() {
151
+ if (hashNavigationInitialized) {
152
+ return;
153
+ }
154
+ hashNavigationInitialized = true;
155
+
156
+ const updateWindowHash = () => {
157
+ const currentPath = window.location.hash.substring(1);
158
+ const expectedPath = mainContentPath() || "";
159
+ if (currentPath !== expectedPath) {
160
+ window.location.hash = expectedPath;
161
+ }
162
+ };
163
+ const navigateFromHash = () => {
164
+ const requestedPath = canonicalizeRoute(window.location.hash.substring(1));
165
+ if (requestedPath !== mainContentPath()) {
166
+ mainContentPath(requestedPath);
167
+ } else {
168
+ updateWindowHash();
169
+ }
170
+ };
171
+
172
+ mainContentPath.subscribe(updateWindowHash);
173
+ window.addEventListener("hashchange", navigateFromHash);
174
+
175
+ if (window.location.hash.substring(1)) {
176
+ navigateFromHash();
177
+ } else {
178
+ updateWindowHash();
179
+ }
180
+ }
181
+
182
+ /** Load global user state required before the root layout renders. */
130
183
  export async function loadGlobals() {
131
184
  if (globalsLoaded) return true;
132
185
  globalsLoaded = true;
@@ -143,19 +196,6 @@ export async function loadGlobals() {
143
196
  sleep(100),
144
197
  ]);
145
198
 
146
- function updateWindowHash() {
147
- const currentPath = window.location.hash.substring(1);
148
- const expectedPath = mainContentPath() || "";
149
- // console.log({ expectedPath, currentPath });
150
- if (currentPath !== expectedPath) {
151
- window.location.hash = expectedPath;
152
- }
153
- mainContentPath(expectedPath);
154
- }
155
- mainContentPath.subscribe(updateWindowHash);
156
- updateWindowHash();
157
- window.addEventListener("hashchange", () => mainContentPath(window.location.hash.substring(1)));
158
-
159
199
  return true;
160
200
  }
161
201
 
@@ -0,0 +1,75 @@
1
+ import {
2
+ type IContractConsumerBase,
3
+ type IHostContractConsumer,
4
+ subscribeHostContractTransport,
5
+ } from "@peers-app/peers-sdk";
6
+ import { useEffect, useState } from "react";
7
+
8
+ /** Live consumer for one host contract, plus the last failure to obtain it. */
9
+ export interface IUseHostContractResult<T extends IContractConsumerBase> {
10
+ /** Current consumer, or `undefined` while the host transport is down. */
11
+ consumer: T | undefined;
12
+ /** Set when `get` rejects, for example when the transport wait times out. */
13
+ error: Error | undefined;
14
+ }
15
+
16
+ /**
17
+ * Resolve a cached local-host contract consumer and keep it current across reconnects.
18
+ *
19
+ * The handle owns caching and disposal. This hook re-reads it when the host transport
20
+ * changes. Read `consumer.observables.*` with `useObservable`.
21
+ *
22
+ * @param accessor Handle from `hostContractConsumer`, stable for the lifetime of the screen module.
23
+ * @returns The current consumer and the last error.
24
+ */
25
+ export function useHostContract<T extends IContractConsumerBase>(
26
+ accessor: IHostContractConsumer<T>,
27
+ ): IUseHostContractResult<T> {
28
+ const [consumer, setConsumer] = useState<T | undefined>();
29
+ const [error, setError] = useState<Error | undefined>();
30
+
31
+ useEffect(() => {
32
+ let cancelled = false;
33
+
34
+ const apply = (next: T): void => {
35
+ if (cancelled) {
36
+ return;
37
+ }
38
+ setConsumer(next);
39
+ setError(undefined);
40
+ };
41
+
42
+ const fail = (cause: unknown): void => {
43
+ if (cancelled) {
44
+ return;
45
+ }
46
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
47
+ };
48
+
49
+ const load = (): void => {
50
+ accessor.get().then(apply, fail);
51
+ };
52
+
53
+ load();
54
+ const unsubscribe = subscribeHostContractTransport(
55
+ (transport) => {
56
+ if (cancelled) {
57
+ return;
58
+ }
59
+ if (!transport) {
60
+ setConsumer(undefined);
61
+ return;
62
+ }
63
+ load();
64
+ },
65
+ { emitCurrent: false },
66
+ );
67
+
68
+ return () => {
69
+ cancelled = true;
70
+ unsubscribe();
71
+ };
72
+ }, [accessor]);
73
+
74
+ return { consumer, error };
75
+ }
package/src/index.tsx CHANGED
@@ -3,6 +3,7 @@ export type { IEditorEffects, IMarkdownEditorProps } from "./components/markdown
3
3
  export { MarkdownEditor } from "./components/markdown-editor/editor";
4
4
  export * from "./components/markdown-editor/editor-inline";
5
5
  export * from "./components/markdown-with-mentions";
6
+ export * from "./components/responsive-section-nav";
6
7
  export * from "./components/sortable-list";
7
8
  export * from "./components/tabs";
8
9
  export { Typeahead, type TypeaheadItem } from "./components/typeahead";
@@ -1,17 +1,6 @@
1
- import { userVar } from "@peers-app/peers-sdk";
2
-
3
- /** Set when the user has completed challenge-response registration (ISO timestamp). */
4
- export const registrationStatusPvar = userVar<string>("PEERS_SERVICES_REGISTERED", {
5
- defaultValue: "",
6
- });
7
-
8
- export const servicesTokenPvar = userVar<string>("PEERS_SERVICES_TOKEN", {
9
- isSecret: true,
10
- defaultValue: "",
11
- });
12
-
13
- /** Store JWT and local "registered" flag after a successful `registerWithPeersServices` call. */
14
- export function persistPeersServicesRegistration(token: string): void {
15
- servicesTokenPvar(token);
16
- registrationStatusPvar(new Date().toISOString());
17
- }
1
+ // The pvar definitions live in peers-sdk so headless hosts and UIs share one set of names.
2
+ export {
3
+ persistPeersServicesRegistration,
4
+ registrationStatusPvar,
5
+ servicesTokenPvar,
6
+ } from "@peers-app/peers-sdk";