@peers-app/peers-ui 0.25.2 → 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
@@ -7,9 +7,16 @@ import {
7
7
  observable,
8
8
  } from "@peers-app/peers-sdk";
9
9
  import { _mainContentPath } from "../globals";
10
+ import {
11
+ canonicalizeRoute,
12
+ IDENTITY_APP_PATH,
13
+ isIdentityPath,
14
+ parseRoute,
15
+ } from "../screens/identity/identity-routes";
10
16
  import { systemPackage } from "../system-apps";
11
17
  import { allPackages, type IPackageWithNavs } from "../ui-router/routes-loader";
12
18
 
19
+ /** One top-level app tab. */
13
20
  export interface TabState {
14
21
  tabId: string;
15
22
  packageId?: string; // 'launcher' for app launcher tab
@@ -18,6 +25,7 @@ export interface TabState {
18
25
  iconClassName?: string;
19
26
  }
20
27
 
28
+ /** The app launcher tab. It is always present and cannot be closed. */
21
29
  export const launcherApp: TabState = {
22
30
  tabId: "launcher",
23
31
  packageId: "launcher",
@@ -39,7 +47,13 @@ const _persistentRecentlyUsedApps = groupUserVar<string[]>("recentlyUsedApps", {
39
47
  defaultValue: [],
40
48
  });
41
49
 
50
+ let finishNavigationRestore: () => void = () => {};
51
+ const navigationReady = new Promise<void>((resolve) => {
52
+ finishNavigationRestore = resolve;
53
+ });
54
+
42
55
  // In-memory observables for UI state (loaded once, then write-through to persistent vars)
56
+ /** Open tabs. Writes through to device storage after the initial restore. */
43
57
  export const activeTabs: Observable<TabState[]> & { loadingPromise: Promise<void> } = (() => {
44
58
  const obs = observable<TabState[]>([launcherApp]);
45
59
 
@@ -48,14 +62,10 @@ export const activeTabs: Observable<TabState[]> & { loadingPromise: Promise<void
48
62
  _persistentActiveTabs(value);
49
63
  });
50
64
 
51
- // Load initial value once
52
- const loadingPromise = _persistentActiveTabs.loadingPromise.then(() => {
53
- obs(_persistentActiveTabs());
54
- });
55
-
56
- return Object.assign(obs, { loadingPromise });
65
+ return Object.assign(obs, { loadingPromise: navigationReady });
57
66
  })();
58
67
 
68
+ /** Id of the tab whose path is showing. */
59
69
  export const activeTabId: Observable<string> & { loadingPromise: Promise<void> } = (() => {
60
70
  const obs = observable<string>("launcher");
61
71
 
@@ -64,14 +74,10 @@ export const activeTabId: Observable<string> & { loadingPromise: Promise<void> }
64
74
  _persistentActiveTabId(value);
65
75
  });
66
76
 
67
- // Load initial value once
68
- const loadingPromise = _persistentActiveTabId.loadingPromise.then(() => {
69
- obs(_persistentActiveTabId());
70
- });
71
-
72
- return Object.assign(obs, { loadingPromise });
77
+ return Object.assign(obs, { loadingPromise: navigationReady });
73
78
  })();
74
79
 
80
+ /** App roots recently opened from the launcher, newest first. */
75
81
  export const recentlyUsedApps: Observable<string[]> & { loadingPromise: Promise<void> } = (() => {
76
82
  const obs = observable<string[]>([]);
77
83
 
@@ -80,85 +86,163 @@ export const recentlyUsedApps: Observable<string[]> & { loadingPromise: Promise<
80
86
  _persistentRecentlyUsedApps(value);
81
87
  });
82
88
 
83
- // Load initial value once
84
- const loadingPromise = _persistentRecentlyUsedApps.loadingPromise.then(() => {
85
- obs(_persistentRecentlyUsedApps());
86
- });
87
-
88
- return Object.assign(obs, { loadingPromise });
89
+ return Object.assign(obs, { loadingPromise: navigationReady });
89
90
  })();
90
91
 
92
+ void Promise.all([
93
+ _persistentActiveTabs.loadingPromise,
94
+ _persistentActiveTabId.loadingPromise,
95
+ _persistentRecentlyUsedApps.loadingPromise,
96
+ ]).then(() => {
97
+ const restored = restoreNavigationState(_persistentActiveTabs(), _persistentActiveTabId());
98
+ activeTabs(restored.tabs);
99
+ if (activeTabId() !== restored.activeId) {
100
+ activeTabId(restored.activeId);
101
+ }
102
+ recentlyUsedApps(migrateRecentPaths(_persistentRecentlyUsedApps()));
103
+ finishNavigationRestore();
104
+ });
105
+
106
+ /** Tab ids whose bodies have been mounted at least once. */
91
107
  export const initializedTabs = new Set<string>();
92
108
 
93
- export function goToTabPath(path: string) {
94
- const tab = activeTabs().find((t) => t.path === path);
95
- if (tab) {
96
- activeTabId(tab.tabId);
97
- } else {
98
- const appInfo = determineAppFromPath(path);
99
- if (appInfo) {
100
- openNewTab({
101
- path: path,
102
- title: appInfo.navItem.displayName || appInfo.navItem.name,
103
- packageId: appInfo.package.packageId,
104
- iconClassName: appInfo.navItem.iconClassName,
105
- });
106
- } else {
107
- // No app matched — navigate by updating the active tab's path (e.g. deep link).
108
- // Must replace the tab object (immutable): TabContentRenderer is memoized on tab.path,
109
- // and mutating in place makes prev/next props compare equal so Router never re-renders.
110
- const currentTabId = activeTabId();
111
- const currentTabs = activeTabs();
112
- const idx = currentTabs.findIndex((t) => t.tabId === currentTabId);
113
- if (idx >= 0 && currentTabs[idx].path !== "") {
114
- const prev = currentTabs[idx];
115
- const newTabs = [...currentTabs];
116
- newTabs[idx] = { ...prev, path };
117
- activeTabs(newTabs);
118
- if (prev.packageId !== "launcher") {
119
- _mainContentPath(path);
120
- }
121
- } else {
122
- openNewTab({
123
- path: path,
124
- title: "New Tab",
125
- packageId: "system-apps",
126
- iconClassName: "bi-question-circle",
127
- });
109
+ /** Options for {@link navigateTo}. */
110
+ export interface NavigateOptions {
111
+ /**
112
+ * When the requested path is an app's root and that app is already open,
113
+ * activate the existing tab without resetting its section.
114
+ */
115
+ preserveSectionOnAppRoot?: boolean;
116
+ }
117
+
118
+ /**
119
+ * Open a path in the tab that owns its app.
120
+ *
121
+ * Legacy identity aliases are rewritten first. A route owned by the current
122
+ * app replaces that tab's path. A route owned by another open app activates
123
+ * that app's tab. Otherwise a new tab is opened.
124
+ *
125
+ * @param rawPath - Requested route, with or without a query string.
126
+ * @param options - App-root behavior. Launcher opens preserve the current section.
127
+ */
128
+ export function navigateTo(rawPath: string, options?: NavigateOptions) {
129
+ const requestedPathname = parseRoute(rawPath).pathname;
130
+ if (!requestedPathname) {
131
+ const launcher = activeTabs().find((tab) => tab.tabId === "launcher") ?? activeTabs()[0];
132
+ if (launcher && activeTabId() !== launcher.tabId) {
133
+ activeTabId(launcher.tabId);
134
+ }
135
+ return;
136
+ }
137
+
138
+ const next = canonicalizeRoute(rawPath);
139
+ const app = determineAppFromPath(next);
140
+ if (options?.preserveSectionOnAppRoot && app && isAppRootRequest(requestedPathname, app)) {
141
+ const existing = findTabForApp(app);
142
+ if (existing) {
143
+ if (activeTabId() !== existing.tabId) {
144
+ activeTabId(existing.tabId);
128
145
  }
146
+ rememberRecentApp(existing.path);
147
+ return;
129
148
  }
130
149
  }
150
+
151
+ if (!app || app.package.packageId === "launcher") {
152
+ openUnresolved(next);
153
+ return;
154
+ }
155
+
156
+ const current = activeTabs().find((tab) => tab.tabId === activeTabId());
157
+ const currentApp = current ? determineAppFromPath(current.path) : undefined;
158
+ if (current && current.packageId !== "launcher" && currentApp && sameApp(currentApp, app)) {
159
+ showTab(current.tabId, next);
160
+ return;
161
+ }
162
+
163
+ const existing = findTabForApp(app);
164
+ if (existing) {
165
+ showTab(existing.tabId, next);
166
+ return;
167
+ }
168
+
169
+ openNewTab({
170
+ path: next,
171
+ title: app.navItem.displayName || app.navItem.name,
172
+ packageId: app.package.packageId,
173
+ iconClassName: app.navItem.iconClassName,
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Open an app or path from the launcher.
179
+ *
180
+ * Choosing an app that is already open keeps the section the user was on.
181
+ *
182
+ * @param path - App root or deep link.
183
+ */
184
+ export function goToTabPath(path: string) {
185
+ navigateTo(path, { preserveSectionOnAppRoot: true });
131
186
  }
132
187
 
188
+ /**
189
+ * Apply a {@link mainContentPath} write to the open tabs.
190
+ *
191
+ * @param _oldPath - Previous route. Retained for existing call sites.
192
+ * @param newPath - Requested route.
193
+ * @param _setNewMainPath - Direct path setter. Navigation updates the path itself.
194
+ */
133
195
  export const handleMainPathChanged = (
134
- oldPath: string,
196
+ _oldPath: string,
135
197
  newPath: string,
136
- setNewMainPath: (path: string) => void,
198
+ _setNewMainPath: (path: string) => void,
137
199
  ) => {
138
- const oldPathPart = oldPath.toLowerCase().trim().split("?")[0];
139
- const newPathPart = newPath.toLowerCase().trim().split("?")[0];
140
- const currentTab = activeTabs().find((t) => t.tabId === activeTabId());
141
- if (oldPathPart === newPathPart || (currentTab && currentTab.path === newPath)) {
142
- // nothing to do
143
- setNewMainPath(newPath);
144
- return;
145
- }
200
+ navigateTo(newPath);
201
+ };
146
202
 
147
- const appInfo = determineAppFromPath(newPath);
148
- if (appInfo && oldPath !== "") {
149
- openNewTab({
150
- path: newPath,
151
- title: appInfo.navItem.displayName || appInfo.navItem.name,
152
- packageId: appInfo.package.packageId,
153
- iconClassName: appInfo.navItem.iconClassName,
154
- });
155
- } else {
156
- setNewMainPath(newPath);
203
+ /**
204
+ * Rewrite stored tabs onto canonical routes.
205
+ *
206
+ * Contacts, Groups, and Devices tabs from before the Identity app become one
207
+ * Identity tab. The active tab is kept when it is one of those duplicates.
208
+ *
209
+ * @param tabs - Persisted tabs.
210
+ * @param activeId - Persisted active tab id.
211
+ */
212
+ export function restoreNavigationState(
213
+ tabs: TabState[],
214
+ activeId: string,
215
+ ): { tabs: TabState[]; activeId: string } {
216
+ const migrated = (tabs.length > 0 ? tabs : [launcherApp]).map(migrateStoredTab);
217
+ const merged = mergeIdentityTabs(migrated, activeId);
218
+ const activeExists = merged.tabs.some((tab) => tab.tabId === merged.activeId);
219
+ return {
220
+ tabs: merged.tabs,
221
+ activeId: activeExists ? merged.activeId : merged.tabs[0].tabId,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Map a stored recent-app path onto the current app root.
227
+ *
228
+ * @param path - Persisted recent path, possibly a legacy alias.
229
+ */
230
+ export function migrateRecentPath(path: string): string {
231
+ const canonical = canonicalizeRoute(path);
232
+ if (isIdentityPath(canonical)) {
233
+ return IDENTITY_APP_PATH;
157
234
  }
158
- };
235
+ return canonical;
236
+ }
159
237
 
160
238
  type AppInfo = { navItem: IAppNav; package: IPackageWithNavs };
161
239
 
240
+ /**
241
+ * Resolve the app that owns a route.
242
+ *
243
+ * @param path - Route, with or without a query string.
244
+ * @returns The matching app, the launcher for an empty path, or `undefined`.
245
+ */
162
246
  export function determineAppFromPath(path: string): AppInfo | undefined {
163
247
  const launcherInfo = {
164
248
  navItem: {
@@ -230,7 +314,138 @@ export function determineAppFromPath(path: string): AppInfo | undefined {
230
314
  return undefined;
231
315
  }
232
316
 
233
- // Utility function for detail screens to update the active tab's title
317
+ function appKey(app: AppInfo): string {
318
+ const nav = app.navItem.navigationPath || app.navItem.name.toLowerCase();
319
+ return `${app.package.packageId}:${nav}`;
320
+ }
321
+
322
+ function sameApp(left: AppInfo, right: AppInfo): boolean {
323
+ return appKey(left) === appKey(right);
324
+ }
325
+
326
+ function isAppRootRequest(pathname: string, app: AppInfo): boolean {
327
+ const nav = (app.navItem.navigationPath || app.navItem.name).toLowerCase();
328
+ return pathname.toLowerCase() === nav;
329
+ }
330
+
331
+ function findTabForApp(app: AppInfo): TabState | undefined {
332
+ const key = appKey(app);
333
+ return activeTabs().find((tab) => {
334
+ const info = determineAppFromPath(tab.path);
335
+ return info ? appKey(info) === key : false;
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Replace a tab's path immutably so the memoized tab body re-renders.
341
+ * Activating a different tab lets the tab subscription publish the path.
342
+ */
343
+ function showTab(tabId: string, path: string) {
344
+ const tabs = activeTabs();
345
+ const index = tabs.findIndex((tab) => tab.tabId === tabId);
346
+ if (index < 0) {
347
+ return;
348
+ }
349
+ const current = tabs[index];
350
+ if (current.path !== path) {
351
+ const nextTabs = [...tabs];
352
+ nextTabs[index] = { ...current, path };
353
+ activeTabs(nextTabs);
354
+ }
355
+ if (activeTabId() !== tabId) {
356
+ activeTabId(tabId);
357
+ } else if (_mainContentPath() !== path) {
358
+ _mainContentPath(path);
359
+ }
360
+ rememberRecentApp(path);
361
+ }
362
+
363
+ function openUnresolved(path: string) {
364
+ const current = activeTabs().find((tab) => tab.tabId === activeTabId());
365
+ if (current && current.packageId === "system-apps") {
366
+ showTab(current.tabId, path);
367
+ return;
368
+ }
369
+ openNewTab({
370
+ path,
371
+ title: "New Tab",
372
+ packageId: "system-apps",
373
+ iconClassName: "bi-question-circle",
374
+ });
375
+ }
376
+
377
+ function rememberRecentApp(path: string) {
378
+ const app = determineAppFromPath(canonicalizeRoute(path));
379
+ if (!app || app.package.packageId === "launcher") {
380
+ return;
381
+ }
382
+ const recentPath =
383
+ app.package.packageId === systemPackage.packageId ? app.navItem.navigationPath || path : path;
384
+ if (!recentPath) {
385
+ return;
386
+ }
387
+ const recent = recentlyUsedApps();
388
+ const filtered = recent.filter((entry) => entry !== recentPath);
389
+ const next = [recentPath, ...filtered].slice(0, 8);
390
+ if (next.length === recent.length && next.every((entry, index) => entry === recent[index])) {
391
+ return;
392
+ }
393
+ recentlyUsedApps(next);
394
+ }
395
+
396
+ function migrateStoredTab(tab: TabState): TabState {
397
+ const path = canonicalizeRoute(tab.path);
398
+ if (path === tab.path) {
399
+ return tab;
400
+ }
401
+ const app = determineAppFromPath(path);
402
+ const identity = app?.navItem.navigationPath === IDENTITY_APP_PATH;
403
+ return {
404
+ ...tab,
405
+ path,
406
+ packageId: app?.package.packageId ?? tab.packageId,
407
+ title: identity ? app?.navItem.displayName || app?.navItem.name || tab.title : tab.title,
408
+ iconClassName: identity ? app?.navItem.iconClassName || tab.iconClassName : tab.iconClassName,
409
+ };
410
+ }
411
+
412
+ function mergeIdentityTabs(
413
+ tabs: TabState[],
414
+ activeId: string,
415
+ ): { tabs: TabState[]; activeId: string } {
416
+ const identityTabs = tabs.filter((tab) => isIdentityPath(tab.path));
417
+ if (identityTabs.length <= 1) {
418
+ return { tabs, activeId };
419
+ }
420
+ const keeper = identityTabs.find((tab) => tab.tabId === activeId) ?? identityTabs[0];
421
+ const remove = new Set(
422
+ identityTabs.filter((tab) => tab.tabId !== keeper.tabId).map((tab) => tab.tabId),
423
+ );
424
+ return {
425
+ tabs: tabs.filter((tab) => !remove.has(tab.tabId)),
426
+ activeId: remove.has(activeId) ? keeper.tabId : activeId,
427
+ };
428
+ }
429
+
430
+ function migrateRecentPaths(paths: string[]): string[] {
431
+ const seen = new Set<string>();
432
+ const next: string[] = [];
433
+ for (const path of paths) {
434
+ const migrated = migrateRecentPath(path);
435
+ if (!migrated || seen.has(migrated)) {
436
+ continue;
437
+ }
438
+ seen.add(migrated);
439
+ next.push(migrated);
440
+ }
441
+ return next.slice(0, 8);
442
+ }
443
+
444
+ /**
445
+ * Set the active tab's title.
446
+ *
447
+ * @param newTitle - Title shown on the tab strip.
448
+ */
234
449
  export const updateActiveTabTitle = (newTitle: string) => {
235
450
  // TODO Fix this to use a more reliable update mechanism
236
451
  // using setTimeout to get around a race condition for setting title on new tabs
@@ -261,7 +476,12 @@ export const setSingleTabMode = (enabled: boolean) => {
261
476
  singleTabModeEnabled = enabled;
262
477
  };
263
478
 
264
- // Global function to open content in new tab
479
+ /**
480
+ * Open a tab, or activate an existing tab with the same package and path.
481
+ *
482
+ * @param tab - Tab contents. `tabId` is assigned here.
483
+ * @param forceNew - When true, always append a tab instead of reusing one.
484
+ */
265
485
  export const openNewTab = (tab: Omit<TabState, "tabId">, forceNew = false) => {
266
486
  const newTab: TabState = {
267
487
  ...tab,
@@ -296,15 +516,12 @@ export const openNewTab = (tab: Omit<TabState, "tabId">, forceNew = false) => {
296
516
  activeTabId(newTab.tabId);
297
517
  }
298
518
 
299
- // Update recently used (track by path instead of packageId)
300
519
  if (tab.path && tab.packageId !== "launcher") {
301
- const recent = recentlyUsedApps();
302
- const filtered = recent.filter((path) => path !== tab.path);
303
- recentlyUsedApps([tab.path, ...filtered].slice(0, 8));
520
+ rememberRecentApp(tab.path);
304
521
  }
305
522
  };
306
523
 
307
- // Close the current active tab (but not launcher)
524
+ /** Close the active tab. The launcher tab stays open. */
308
525
  export const closeCurrentTab = () => {
309
526
  const currentTabs = activeTabs();
310
527
  const _activeTabId = activeTabId();
@@ -326,7 +543,7 @@ export const closeCurrentTab = () => {
326
543
  }
327
544
  };
328
545
 
329
- // Navigate to next tab
546
+ /** Activate the next open tab, wrapping around. */
330
547
  export const switchToNextTab = () => {
331
548
  const currentTabs = activeTabs();
332
549
  const activeTab = activeTabId();
@@ -338,7 +555,7 @@ export const switchToNextTab = () => {
338
555
  activeTabId(currentTabs[nextIndex].tabId);
339
556
  };
340
557
 
341
- // Navigate to previous tab
558
+ /** Activate the previous open tab, wrapping around. */
342
559
  export const switchToPreviousTab = () => {
343
560
  const currentTabs = activeTabs();
344
561
  const activeTab = activeTabId();
@@ -1,9 +0,0 @@
1
- /** Screen props supplied by the router. */
2
- export interface InvitesScreenProps {
3
- /** When `true`, the Accept panel starts open (the `invites/accept` route). */
4
- accept?: boolean;
5
- }
6
- /**
7
- * Invites hub: accept a pasted/scanned/deep-linked invite and review everything pending.
8
- */
9
- export declare function InvitesScreen(props: InvitesScreenProps): import("react/jsx-runtime").JSX.Element;
@@ -1,36 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.InvitesScreen = InvitesScreen;
4
- const jsx_runtime_1 = require("react/jsx-runtime");
5
- const react_1 = require("react");
6
- const ui_loader_1 = require("../../ui-router/ui-loader");
7
- const accept_invite_1 = require("./accept-invite");
8
- const invite_capture_1 = require("./invite-capture");
9
- const pending_invites_list_1 = require("./pending-invites-list");
10
- /**
11
- * Invites hub: accept a pasted/scanned/deep-linked invite and review everything pending.
12
- */
13
- function InvitesScreen(props) {
14
- const [accepting, setAccepting] = (0, react_1.useState)(Boolean(props.accept || (0, invite_capture_1.peekPendingInvite)()));
15
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-3", style: { maxWidth: 720 }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center mb-3", children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-0", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-envelope-paper me-2" }), "Invites"] }), !accepting ? ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-primary btn-sm", onClick: () => setAccepting(true), children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-arrow-in-right me-1" }), "Accept an invite"] })) : null] }), accepting ? ((0, jsx_runtime_1.jsx)("div", { className: "card mb-4", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h6", { className: "card-title", children: "Accept an invite" }), (0, jsx_runtime_1.jsx)(accept_invite_1.AcceptInvite, { onDone: () => setAccepting(false) })] }) })) : null, (0, jsx_runtime_1.jsx)(pending_invites_list_1.PendingInvitesList, { title: "Waiting on you or others" }), (0, jsx_runtime_1.jsxs)("details", { className: "mt-3", children: [(0, jsx_runtime_1.jsx)("summary", { className: "small text-muted", style: { cursor: "pointer" }, children: "Recent history" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", children: (0, jsx_runtime_1.jsx)(pending_invites_list_1.PendingInvitesList, { statuses: ["accepted", "declined", "expired", "revoked"] }) })] })] }));
16
- }
17
- (0, ui_loader_1.registerInternalPeersUI)({
18
- peersUIId: "00invites0screen000000001",
19
- component: InvitesScreen,
20
- routes: [
21
- {
22
- isMatch: (props, context) => {
23
- if (context.path === "invites") {
24
- return true;
25
- }
26
- if (context.path === invite_capture_1.ACCEPT_INVITE_PATH) {
27
- props.accept = true;
28
- return true;
29
- }
30
- return false;
31
- },
32
- uiCategory: "screen",
33
- priority: 2,
34
- },
35
- ],
36
- });
@@ -1,2 +0,0 @@
1
- import type { IAppNav } from "@peers-app/peers-sdk";
2
- export declare const accountApp: IAppNav;
@@ -1,8 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.accountApp = void 0;
4
- exports.accountApp = {
5
- name: "Account",
6
- iconClassName: "bi-person-circle",
7
- navigationPath: "account",
8
- };
@@ -1,2 +0,0 @@
1
- import type { IAppNav } from "@peers-app/peers-sdk";
2
- export declare const contactsApp: IAppNav;
@@ -1,9 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.contactsApp = void 0;
4
- exports.contactsApp = {
5
- name: "Contacts",
6
- displayName: "Contacts",
7
- iconClassName: "bi-person-fill-check",
8
- navigationPath: "contacts",
9
- };
@@ -1,2 +0,0 @@
1
- import type { IAppNav } from "@peers-app/peers-sdk";
2
- export declare const groupsApp: IAppNav;
@@ -1,9 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.groupsApp = void 0;
4
- exports.groupsApp = {
5
- name: "Groups",
6
- displayName: "Groups",
7
- iconClassName: "bi-people-fill",
8
- navigationPath: "groups",
9
- };
@@ -1,2 +0,0 @@
1
- import type { IAppNav } from "@peers-app/peers-sdk";
2
- export declare const invitesApp: IAppNav;
@@ -1,9 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.invitesApp = void 0;
4
- exports.invitesApp = {
5
- name: "Invites",
6
- displayName: "Invites",
7
- iconClassName: "bi-envelope-paper",
8
- navigationPath: "invites",
9
- };
@@ -1,78 +0,0 @@
1
- import { useState } from "react";
2
- import { registerInternalPeersUI } from "../../ui-router/ui-loader";
3
- import { AcceptInvite } from "./accept-invite";
4
- import { ACCEPT_INVITE_PATH, peekPendingInvite } from "./invite-capture";
5
- import { PendingInvitesList } from "./pending-invites-list";
6
-
7
- /** Screen props supplied by the router. */
8
- export interface InvitesScreenProps {
9
- /** When `true`, the Accept panel starts open (the `invites/accept` route). */
10
- accept?: boolean;
11
- }
12
-
13
- /**
14
- * Invites hub: accept a pasted/scanned/deep-linked invite and review everything pending.
15
- */
16
- export function InvitesScreen(props: InvitesScreenProps) {
17
- const [accepting, setAccepting] = useState(Boolean(props.accept || peekPendingInvite()));
18
- return (
19
- <div className="container-fluid p-3" style={{ maxWidth: 720 }}>
20
- <div className="d-flex justify-content-between align-items-center mb-3">
21
- <h4 className="mb-0">
22
- <i className="bi bi-envelope-paper me-2" />
23
- Invites
24
- </h4>
25
- {!accepting ? (
26
- <button
27
- type="button"
28
- className="btn btn-primary btn-sm"
29
- onClick={() => setAccepting(true)}
30
- >
31
- <i className="bi bi-box-arrow-in-right me-1" />
32
- Accept an invite
33
- </button>
34
- ) : null}
35
- </div>
36
-
37
- {accepting ? (
38
- <div className="card mb-4">
39
- <div className="card-body">
40
- <h6 className="card-title">Accept an invite</h6>
41
- <AcceptInvite onDone={() => setAccepting(false)} />
42
- </div>
43
- </div>
44
- ) : null}
45
-
46
- <PendingInvitesList title="Waiting on you or others" />
47
- <details className="mt-3">
48
- <summary className="small text-muted" style={{ cursor: "pointer" }}>
49
- Recent history
50
- </summary>
51
- <div className="mt-2">
52
- <PendingInvitesList statuses={["accepted", "declined", "expired", "revoked"]} />
53
- </div>
54
- </details>
55
- </div>
56
- );
57
- }
58
-
59
- registerInternalPeersUI({
60
- peersUIId: "00invites0screen000000001",
61
- component: InvitesScreen,
62
- routes: [
63
- {
64
- isMatch: (props, context) => {
65
- if (context.path === "invites") {
66
- return true;
67
- }
68
- if (context.path === ACCEPT_INVITE_PATH) {
69
- props.accept = true;
70
- return true;
71
- }
72
- return false;
73
- },
74
- uiCategory: "screen",
75
- priority: 2,
76
- },
77
- ],
78
- });
@@ -1,7 +0,0 @@
1
- import type { IAppNav } from "@peers-app/peers-sdk";
2
-
3
- export const accountApp: IAppNav = {
4
- name: "Account",
5
- iconClassName: "bi-person-circle",
6
- navigationPath: "account",
7
- };