@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
@@ -1,89 +1,117 @@
1
1
  import {
2
+ type GroupMemberRole,
3
+ GroupMembers,
2
4
  Groups,
3
5
  getMe,
6
+ getRoleLabel,
4
7
  getUserContext,
5
- type ICursorIterable,
6
8
  type IGroup,
7
9
  newid,
8
10
  newKeys,
9
- observable,
10
11
  rpcServerCalls,
11
12
  saveGroupSecretKey,
12
13
  setUserTrustLevel,
13
14
  TrustLevel,
15
+ type UserContext,
14
16
  Users,
15
17
  } from "@peers-app/peers-sdk";
16
- import type React from "react";
17
- import { useCallback, useEffect, useState } from "react";
18
- import { Input } from "../../components/input";
19
- import { LazyList } from "../../components/lazy-list";
18
+ import { useEffect, useMemo, useState } from "react";
20
19
  import { LoadingIndicator } from "../../components/loading-indicator";
21
- import { MarkdownWithMentions } from "../../components/markdown-with-mentions";
22
- import { isDesktop, mainContentPath } from "../../globals";
23
- import { useObservable, usePromise } from "../../hooks";
24
- import { registerInternalPeersUI } from "../../ui-router/ui-loader";
20
+ import { mainContentPath } from "../../globals";
21
+ import { usePromise } from "../../hooks";
22
+ import { IDENTITY_GROUP_NEW_PATH, IDENTITY_GROUPS_PATH } from "../identity/identity-routes";
23
+ import { PendingWorkLink } from "../invites/activity-view";
25
24
 
26
- export function GroupList() {
25
+ /** Search, summarize, and create groups owned by the signed-in user. */
26
+ export function GroupList(props: { creating?: boolean } = {}) {
27
27
  const userContext = usePromise(getUserContext);
28
- const [searchTextObs] = useState(() => observable(""));
29
- const [searchText] = useObservable(searchTextObs);
30
- const [cursorObs] = useState(() => observable<ICursorIterable<IGroup> | undefined>());
28
+ const [creating, setCreating] = useState(props.creating ?? false);
29
+ const [draftName, setDraftName] = useState("");
30
+ const [searchText, setSearchText] = useState("");
31
+ const [summaries, setSummaries] = useState<IGroupSummary[] | undefined>();
32
+ const [error, setError] = useState("");
33
+ const [creatingBusy, setCreatingBusy] = useState(false);
34
+ const [groupRevision, setGroupRevision] = useState(0);
31
35
 
32
- // All hooks must be called before any conditional returns
33
- const groupsTable = userContext ? Groups(userContext.userDataContext) : null;
34
-
35
- const newCursor = useCallback(async () => {
36
- if (!groupsTable) return;
37
-
38
- const filter = {
39
- $or: [{ name: { $matchWords: searchText } }, { description: { $matchWords: searchText } }],
40
- disabled: { $ne: true },
36
+ useEffect(() => {
37
+ if (!userContext) {
38
+ return;
39
+ }
40
+ let disposed = false;
41
+ const subscriptions: Array<{ unsubscribe: () => void }> = [];
42
+ const refresh = async () => {
43
+ try {
44
+ const next = await loadGroupSummaries(userContext);
45
+ if (!disposed) {
46
+ setSummaries(next);
47
+ setError("");
48
+ }
49
+ } catch (err) {
50
+ if (!disposed) {
51
+ setError((err as Error).message || "Could not load groups.");
52
+ }
53
+ }
41
54
  };
42
- const cursor = await groupsTable.cursor(searchText ? filter : { disabled: { $ne: true } }, {
43
- sortBy: ["name"],
44
- ...(searchText ? {} : { textSearch: searchText }),
55
+ const table = Groups(userContext.userDataContext);
56
+ subscriptions.push(table.dataChanged.subscribe(() => setGroupRevision((value) => value + 1)));
57
+ void table.list({ disabled: { $ne: true } }).then((groups) => {
58
+ if (disposed) {
59
+ return;
60
+ }
61
+ for (const group of groups) {
62
+ subscriptions.push(
63
+ GroupMembers(userContext.getDataContext(group.groupId)).dataChanged.subscribe(
64
+ () => void refresh(),
65
+ ),
66
+ );
67
+ }
68
+ void refresh();
45
69
  });
46
- cursorObs(cursor);
47
- return cursor;
48
- }, [groupsTable, searchText, cursorObs]);
70
+ return () => {
71
+ disposed = true;
72
+ for (const subscription of subscriptions) {
73
+ subscription.unsubscribe();
74
+ }
75
+ };
76
+ }, [groupRevision, userContext]);
49
77
 
50
78
  useEffect(() => {
51
- if (groupsTable) {
52
- void newCursor();
79
+ setCreating(props.creating ?? false);
80
+ }, [props.creating]);
81
+
82
+ const filtered = useMemo(() => {
83
+ const query = searchText.trim().toLocaleLowerCase();
84
+ if (!query) {
85
+ return summaries ?? [];
53
86
  }
54
- }, [groupsTable, newCursor]);
87
+ return (summaries ?? []).filter(
88
+ ({ group }) =>
89
+ group.name.toLocaleLowerCase().includes(query) ||
90
+ group.description.toLocaleLowerCase().includes(query),
91
+ );
92
+ }, [searchText, summaries]);
55
93
 
56
- // Early return after all hooks
57
- if (!userContext) {
58
- return <LoadingIndicator />;
94
+ function startCreate() {
95
+ setDraftName(searchText.trim());
96
+ mainContentPath(IDENTITY_GROUP_NEW_PATH);
59
97
  }
60
98
 
61
- async function loadMore(existing: IGroup[]): Promise<IGroup[]> {
62
- if (!groupsTable) return [];
63
-
64
- const moreMatches: IGroup[] = [];
65
- const cursor = cursorObs() || (await newCursor());
66
- if (!cursor) return [];
67
-
68
- for await (const nextRecord of cursor) {
69
- if (existing.some((e) => e.groupId === nextRecord.groupId)) continue;
70
- if (moreMatches.some((e) => e.groupId === nextRecord.groupId)) continue;
71
- moreMatches.push(nextRecord);
72
- if (searchText.length && moreMatches.length > 5) break;
73
- if (moreMatches.length >= 10) break;
99
+ async function confirmCreate() {
100
+ const name = draftName.trim();
101
+ if (!name) {
102
+ return;
74
103
  }
75
- if (moreMatches.length === 0) {
76
- cursorObs(undefined);
104
+ setCreatingBusy(true);
105
+ setError("");
106
+ try {
107
+ setDraftName("");
108
+ setSearchText("");
109
+ await newRecord(name);
110
+ } catch (err) {
111
+ setError((err as Error).message || "Could not create the group.");
112
+ } finally {
113
+ setCreatingBusy(false);
77
114
  }
78
- return moreMatches;
79
- }
80
-
81
- async function searchSubmit(evt: React.KeyboardEvent<HTMLInputElement>) {
82
- if (evt.key !== "Enter") return;
83
- const name = searchText.trim();
84
- if (!name) return;
85
- searchTextObs("");
86
- await newRecord(name);
87
115
  }
88
116
 
89
117
  async function newRecord(name: string) {
@@ -127,250 +155,161 @@ export function GroupList() {
127
155
  console.error("Error auto-installing peers-core for group:", err);
128
156
  });
129
157
 
130
- mainContentPath(`groups/${group.groupId}`);
158
+ mainContentPath(`${IDENTITY_GROUPS_PATH}/${group.groupId}`);
131
159
  return group;
132
160
  }
133
161
 
134
- function renderItem(group: IGroup) {
135
- const groupIcon = group.iconClassName || "bi-people-fill";
136
- return (
137
- <div key={group.groupId} className="container-fluid pb-4">
138
- <i className={groupIcon}></i>&nbsp;
139
- <a href={`#groups/${group.groupId}`}>{group.name || "<unnamed-group>"}</a>
140
- <div style={{ paddingLeft: "20px" }}>
141
- <MarkdownWithMentions content={group.description || ""} />
142
- </div>
143
- </div>
144
- );
145
- }
146
-
147
162
  return (
148
- <div className="container-fluid">
149
- <div className="input-group mt-3 mb-3">
150
- <Input
151
- value={searchTextObs}
152
- className="form-control"
153
- placeholder="Search or create group"
154
- autoFocus={!!isDesktop()}
155
- onKeyUp={(evt) => searchSubmit(evt)}
156
- />
157
- <ImportGroupButton />
163
+ <div className="container-xxl p-3">
164
+ <div className="mb-3">
165
+ <h4 className="mb-1">
166
+ <i className="bi-people-fill me-2" />
167
+ Groups
168
+ </h4>
169
+ <div className="small text-muted">Shared spaces, membership, and access</div>
158
170
  </div>
159
-
160
- <div className="peers-list-container">
161
- <LazyList
162
- resetTrigger={searchText}
163
- loadMore={loadMore}
164
- scrollThreshold={0.6}
165
- renderItems={(groups) => {
166
- return groups.map(renderItem);
167
- }}
168
- loadingIndicator={
169
- <div className="d-flex justify-content-center" style={{ height: 200 }}>
170
- <LoadingIndicator />
171
- </div>
172
- }
173
- endOfList={
174
- <div className="d-flex justify-content-center" style={{ height: 200 }}>
175
- {/* <span className="h3">End of List</span> */}
176
- </div>
177
- }
171
+ {creating ? (
172
+ <div className="card mb-3">
173
+ <div className="card-body">
174
+ <h6 className="card-title">Create a group</h6>
175
+ <form
176
+ className="input-group"
177
+ onSubmit={(event) => {
178
+ event.preventDefault();
179
+ void confirmCreate();
180
+ }}
181
+ >
182
+ <input
183
+ id="new-group-name"
184
+ className="form-control"
185
+ value={draftName}
186
+ onChange={(event) => setDraftName(event.target.value)}
187
+ placeholder="Group name"
188
+ aria-label="Group name"
189
+ />
190
+ <button
191
+ type="submit"
192
+ className="btn btn-primary"
193
+ disabled={!draftName.trim() || creatingBusy}
194
+ >
195
+ {creatingBusy ? "Creating…" : "Create"}
196
+ </button>
197
+ <a href={`#${IDENTITY_GROUPS_PATH}`} className="btn btn-outline-secondary">
198
+ Cancel
199
+ </a>
200
+ </form>
201
+ </div>
202
+ </div>
203
+ ) : null}
204
+ <PendingWorkLink type="group" />
205
+ <div className="position-relative my-3">
206
+ <i
207
+ className="bi bi-search position-absolute text-muted"
208
+ style={{ left: 12, top: "50%", transform: "translateY(-50%)" }}
209
+ />
210
+ <input
211
+ type="search"
212
+ className="form-control ps-5"
213
+ value={searchText}
214
+ onChange={(event) => setSearchText(event.target.value)}
215
+ placeholder="Search groups"
216
+ aria-label="Search groups"
178
217
  />
179
218
  </div>
219
+
220
+ {error ? <div className="alert alert-danger">{error}</div> : null}
221
+ {!summaries && !error ? <LoadingIndicator /> : null}
222
+ {summaries && filtered.length === 0 ? (
223
+ <div className="border rounded p-4 text-center">
224
+ <i className="bi bi-people fs-2 text-muted" />
225
+ <h6 className="mt-2">{searchText ? "No matching groups" : "No groups yet"}</h6>
226
+ <p className="small text-muted">
227
+ {searchText
228
+ ? "Try a different name or description."
229
+ : "Create a shared space or use an invite to join one."}
230
+ </p>
231
+ {!searchText ? (
232
+ <button type="button" className="btn btn-primary btn-sm" onClick={startCreate}>
233
+ Create your first group
234
+ </button>
235
+ ) : null}
236
+ </div>
237
+ ) : null}
238
+ {filtered.length > 0 ? (
239
+ <div className="list-group">
240
+ {filtered.map((summary) => (
241
+ <GroupSummaryRow key={summary.group.groupId} summary={summary} />
242
+ ))}
243
+ </div>
244
+ ) : null}
180
245
  </div>
181
246
  );
182
247
  }
183
248
 
184
- const ImportGroupButton = () => {
185
- const [isImporting, setIsImporting] = useState(false);
186
- const [showImportDialog, setShowImportDialog] = useState(false);
187
- const [importText, setImportText] = useState("");
188
- const [importResult, setImportResult] = useState<{
189
- success: boolean;
190
- message: string;
191
- groupId?: string;
192
- } | null>(null);
193
-
194
- const handleImport = async () => {
195
- if (!importText.trim()) {
196
- setImportResult({
197
- success: false,
198
- message: "Please paste the group share JSON data",
199
- });
200
- return;
201
- }
202
-
203
- setIsImporting(true);
204
- setImportResult(null);
205
-
206
- try {
207
- const groupId = await rpcServerCalls.importGroupShare(importText.trim());
208
- setImportResult({
209
- success: true,
210
- message: `Group imported successfully!`,
211
- groupId,
212
- });
213
- setImportText("");
249
+ /** Data shown for one group in the Identity list. */
250
+ export interface IGroupSummary {
251
+ /** Signed group record. */
252
+ group: IGroup;
253
+ /** Number of active memberships. */
254
+ memberCount: number;
255
+ /** Signed-in user's role in this group. */
256
+ myRole?: GroupMemberRole;
257
+ }
214
258
 
215
- // Navigate to the imported group after a brief delay
216
- setTimeout(() => {
217
- mainContentPath(`groups/${groupId}`);
218
- setShowImportDialog(false);
219
- setImportResult(null);
220
- }, 2000);
221
- } catch (error: unknown) {
222
- setImportResult({
223
- success: false,
224
- message: `Import failed: ${error instanceof Error ? error.message : String(error)}`,
259
+ async function loadGroupSummaries(userContext: UserContext): Promise<IGroupSummary[]> {
260
+ const groups = await Groups(userContext.userDataContext).list({ disabled: { $ne: true } });
261
+ const summaries = await Promise.all(
262
+ groups.map(async (group) => {
263
+ const members = await GroupMembers(userContext.getDataContext(group.groupId)).list({
264
+ deleted: { $ne: true },
225
265
  });
226
- } finally {
227
- setIsImporting(false);
228
- }
229
- };
230
-
231
- const handlePasteFromClipboard = async () => {
232
- try {
233
- if (navigator.clipboard?.readText) {
234
- const clipboardText = await navigator.clipboard.readText();
235
- setImportText(clipboardText);
236
- }
237
- } catch (_error) {
238
- console.log("Failed to read from clipboard - user can paste manually");
239
- }
240
- };
266
+ return {
267
+ group,
268
+ memberCount: members.length,
269
+ myRole: members.find((member) => member.userId === userContext.userId)?.role,
270
+ };
271
+ }),
272
+ );
273
+ return summaries.sort((left, right) => left.group.name.localeCompare(right.group.name));
274
+ }
241
275
 
276
+ /** Compact, fully clickable group summary row. */
277
+ export function GroupSummaryRow({ summary }: { summary: IGroupSummary }) {
278
+ const { group } = summary;
242
279
  return (
243
- <>
244
- <button
245
- className="btn btn-outline-secondary dropdown-toggle"
246
- type="button"
247
- data-bs-toggle="dropdown"
248
- aria-expanded="false"
280
+ <a
281
+ href={`#${IDENTITY_GROUPS_PATH}/${group.groupId}`}
282
+ className="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3"
283
+ >
284
+ <span
285
+ className="rounded-3 bg-body-secondary d-inline-flex align-items-center justify-content-center flex-shrink-0"
286
+ style={{ width: 44, height: 44 }}
287
+ aria-hidden="true"
249
288
  >
250
- <i className="bi-plus-lg"></i>
251
- </button>
252
- <ul className="dropdown-menu dropdown-menu-end">
253
- <li>
254
- <button className="dropdown-item" onClick={() => setShowImportDialog(true)}>
255
- <i className="bi-download me-2"></i>
256
- Import Shared Group
257
- </button>
258
- </li>
259
- </ul>
260
-
261
- {showImportDialog && (
262
- <div
263
- className="position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
264
- style={{ backgroundColor: "rgba(0,0,0,0.5)", zIndex: 1050 }}
265
- >
266
- <div className="card" style={{ maxWidth: "600px", width: "90%" }}>
267
- <div className="card-header d-flex justify-content-between align-items-center">
268
- <h6 className="mb-0">
269
- <i className="bi-download me-2"></i>
270
- Import Shared Group
271
- </h6>
272
- <button
273
- className="btn-close"
274
- onClick={() => {
275
- setShowImportDialog(false);
276
- setImportText("");
277
- setImportResult(null);
278
- }}
279
- aria-label="Close"
280
- ></button>
281
- </div>
282
- <div className="card-body">
283
- <p className="mb-3">
284
- Paste the group share JSON data below to import a shared group with its admin
285
- members.
286
- </p>
287
-
288
- <div className="mb-3">
289
- <label className="form-label">Group Share JSON:</label>
290
- <div className="d-flex gap-2 mb-2">
291
- <button
292
- className="btn btn-secondary btn-sm"
293
- onClick={handlePasteFromClipboard}
294
- title="Paste from clipboard"
295
- >
296
- <i className="bi-clipboard me-1"></i>
297
- Paste from Clipboard
298
- </button>
299
- </div>
300
- <textarea
301
- className="form-control font-monospace"
302
- rows={8}
303
- value={importText}
304
- onChange={(e) => setImportText(e.target.value)}
305
- placeholder="Paste the group share JSON here..."
306
- style={{ fontSize: "0.9em" }}
307
- />
308
- </div>
309
-
310
- {importResult && (
311
- <div className={`alert alert-${importResult.success ? "success" : "danger"} mb-3`}>
312
- <i
313
- className={`bi-${importResult.success ? "check-circle" : "exclamation-triangle"} me-2`}
314
- ></i>
315
- {importResult.message}
316
- {importResult.success && importResult.groupId && (
317
- <div className="mt-2">
318
- <small>Redirecting to group in 2 seconds...</small>
319
- </div>
320
- )}
321
- </div>
322
- )}
323
-
324
- <div className="text-end">
325
- <button
326
- className="btn btn-secondary me-2"
327
- onClick={() => {
328
- setShowImportDialog(false);
329
- setImportText("");
330
- setImportResult(null);
331
- }}
332
- disabled={isImporting}
333
- >
334
- Cancel
335
- </button>
336
- <button
337
- className="btn btn-primary"
338
- onClick={handleImport}
339
- disabled={isImporting || !importText.trim()}
340
- >
341
- {isImporting ? (
342
- <>
343
- <span
344
- className="spinner-border spinner-border-sm me-1"
345
- role="status"
346
- aria-hidden="true"
347
- ></span>
348
- Importing...
349
- </>
350
- ) : (
351
- <>
352
- <i className="bi-download me-1"></i>
353
- Import Group
354
- </>
355
- )}
356
- </button>
357
- </div>
358
- </div>
359
- </div>
360
- </div>
361
- )}
362
- </>
289
+ <i className={group.iconClassName || "bi bi-people-fill"} />
290
+ </span>
291
+ <span className="flex-grow-1 overflow-hidden">
292
+ <span className="fw-semibold d-block text-truncate">{group.name || "Unnamed group"}</span>
293
+ <span className="small text-muted d-block text-truncate">
294
+ {plainDescription(group.description) || "No description yet"}
295
+ </span>
296
+ </span>
297
+ <span className="d-flex align-items-center gap-2 flex-wrap justify-content-end">
298
+ <span className="badge text-bg-light border">
299
+ {summary.memberCount} member{summary.memberCount === 1 ? "" : "s"}
300
+ </span>
301
+ {summary.myRole !== undefined ? (
302
+ <span className="badge text-bg-secondary">{getRoleLabel(summary.myRole)}</span>
303
+ ) : null}
304
+ </span>
305
+ <i className="bi bi-chevron-right text-muted" aria-hidden="true" />
306
+ </a>
363
307
  );
364
- };
308
+ }
365
309
 
366
- registerInternalPeersUI({
367
- peersUIId: "00mf8qrs004w5u2wqk64d2yp6",
368
- component: GroupList,
369
- routes: [
370
- {
371
- isMatch: (_props, context) => context.path === "groups",
372
- uiCategory: "screen",
373
- priority: 2,
374
- },
375
- ],
376
- });
310
+ function plainDescription(markdown: string): string {
311
+ return markdown
312
+ .replace(/[#_*`>[\]()!-]/g, " ")
313
+ .replace(/\s+/g, " ")
314
+ .trim();
315
+ }
@@ -24,6 +24,7 @@ interface GroupMembersUIProps {
24
24
  userContext: UserContext;
25
25
  }
26
26
 
27
+ /** List group memberships and expose role-appropriate invite and member controls. */
27
28
  export const GroupMembersUI = (props: GroupMembersUIProps) => {
28
29
  const { groupId, userContext } = props;
29
30
 
@@ -202,7 +203,7 @@ export const GroupMembersUI = (props: GroupMembersUIProps) => {
202
203
  {/* Founder info - only show if current user is NOT the founder */}
203
204
  {founderData && founderData.founderUserId !== currentUserId && (
204
205
  <a
205
- href={`#contacts/${founderData.founderUserId}`}
206
+ href={`#identity/people/${founderData.founderUserId}`}
206
207
  className="alert alert-warning py-2 px-3 mb-0 d-flex align-items-center text-decoration-none"
207
208
  style={{ cursor: "pointer" }}
208
209
  >
@@ -273,7 +274,7 @@ export const GroupMembersUI = (props: GroupMembersUIProps) => {
273
274
 
274
275
  {/* View button - Always visible */}
275
276
  <a
276
- href={`#contacts/${member.userId}`}
277
+ href={`#identity/people/${member.userId}`}
277
278
  className="btn btn-outline-primary btn-sm me-2"
278
279
  >
279
280
  <i className="bi-eye me-1" />
@@ -1,8 +1,3 @@
1
- // Import all group screen components to ensure they register their routes
2
- import "./group-list";
3
- import "./group-details";
4
- import "./group-members";
5
-
6
1
  export * from "./group-details";
7
2
  export * from "./group-list";
8
3
  export * from "./group-members";