@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,300 +1,286 @@
1
1
  import {
2
+ GroupMembers,
3
+ Groups,
2
4
  getUserContext,
3
- type ICreatedInvite,
4
- type ICursorIterable,
5
- type IUserWithSource,
6
- observable,
7
- setUserTrustLevel,
5
+ getUserTrustLevel,
6
+ type IUser,
8
7
  TrustLevel,
8
+ type UserContext,
9
9
  Users,
10
- usersCursor,
11
10
  } from "@peers-app/peers-sdk";
12
- import type React from "react";
13
- import { useCallback, useEffect, useState } from "react";
14
- import { Input } from "../../components/input";
15
- import { LazyList } from "../../components/lazy-list";
11
+ import { useEffect, useMemo, useState } from "react";
16
12
  import { LoadingIndicator } from "../../components/loading-indicator";
17
13
  import { TrustLevelBadge } from "../../components/trust-level-badge";
18
14
  import { mainContentPath } from "../../globals";
19
- import { useObservable } from "../../hooks";
20
- import { registerInternalPeersUI } from "../../ui-router/ui-loader";
21
- import { ACCEPT_INVITE_PATH } from "../invites/invite-capture";
22
- import { InviteShare } from "../invites/invite-share";
23
- import { inviteTools } from "../invites/invites-ipc";
24
- import { PendingInvitesList } from "../invites/pending-invites-list";
15
+ import { usePromise } from "../../hooks";
16
+ import { IDENTITY_PEOPLE_PATH, IDENTITY_PEOPLE_SHARE_PATH } from "../identity/identity-routes";
17
+ import { InviteContactPanel } from "../identity/invite-contact-panel";
18
+ import { ProfileInviteSection } from "../identity/profile-invite";
19
+ import { PendingWorkLink } from "../invites/activity-view";
25
20
 
26
- /** Issues a one-off contact invite and shows it as QR/link until dismissed. */
27
- function InviteContactPanel({ onClose }: { onClose: () => void }) {
28
- const [invite, setInvite] = useState<ICreatedInvite | undefined>(undefined);
21
+ /** People directory and route-backed invite/profile actions. */
22
+ export function ContactList(props: { action?: "share" | "invite" } = {}) {
23
+ const userContext = usePromise(getUserContext);
24
+ const [rows, setRows] = useState<IPersonSummary[] | undefined>();
29
25
  const [error, setError] = useState("");
26
+ const [searchText, setSearchText] = useState("");
27
+ const [groupRevision, setGroupRevision] = useState(0);
30
28
 
31
29
  useEffect(() => {
32
- let cancelled = false;
33
- void inviteTools()
34
- .then((tools) => tools.createContactInvite({}))
35
- .then(({ invite: created }) => {
36
- if (!cancelled) setInvite(created);
37
- })
38
- .catch((err) => {
39
- if (!cancelled) setError((err as Error).message || "Could not create an invite.");
40
- });
41
- return () => {
42
- cancelled = true;
43
- };
44
- }, []);
45
-
46
- return (
47
- <div className="card mb-3">
48
- <div className="card-body">
49
- <h6 className="card-title">Invite a contact</h6>
50
- {error ? <div className="alert alert-danger py-2">{error}</div> : null}
51
- {invite ? (
52
- <InviteShare
53
- invite={invite}
54
- description="Send this link or show the QR. When they accept, you are connected — no need to be online at the same time."
55
- shareText="Connect with me on Peers:"
56
- onDone={onClose}
57
- />
58
- ) : !error ? (
59
- <LoadingIndicator />
60
- ) : null}
61
- </div>
62
- </div>
63
- );
64
- }
65
-
66
- export function ContactList() {
67
- const [inviting, setInviting] = useState(false);
68
- const [searchTextObs] = useState(() => observable(""));
69
- const [searchText] = useObservable(searchTextObs);
70
- const [cursorObs] = useState(() => observable<ICursorIterable<IUserWithSource> | undefined>());
71
- const [existingContacts, setExistingContacts] = useState<IUserWithSource[]>([]);
72
-
73
- const newCursor = useCallback(async () => {
74
- const cursor = await usersCursor({ name: { $matchWords: searchText } });
75
- cursorObs(cursor);
76
- return cursor;
77
- }, [searchText, cursorObs]);
78
-
79
- useEffect(() => {
80
- void newCursor();
81
- setExistingContacts([]); // Reset existing contacts when search changes
82
- }, [newCursor]);
83
-
84
- async function loadMore(existing: IUserWithSource[]): Promise<IUserWithSource[]> {
85
- const moreMatches: IUserWithSource[] = [];
86
- const cursor = cursorObs() || (await newCursor());
87
- for await (const nextContact of cursor) {
88
- if (existing.some((e) => e.userId === nextContact.userId)) continue;
89
- if (moreMatches.some((e) => e.userId === nextContact.userId)) continue;
90
- moreMatches.push(nextContact);
91
- if (searchText.length && moreMatches.length > 5) break;
92
- if (moreMatches.length >= 10) break;
30
+ if (!userContext) {
31
+ return;
93
32
  }
94
- if (moreMatches.length === 0) {
95
- cursorObs(undefined);
96
- }
97
- setExistingContacts(existing.concat(moreMatches)); // Track all loaded contacts
98
- return moreMatches;
99
- }
100
-
101
- function isValidUserId(id: string): boolean {
102
- // Check if it's a valid ID format (alphanumeric, typically 25 characters)
103
- return /^[a-zA-Z0-9]{20,30}$/.test(id);
104
- }
105
-
106
- async function createContact(userId: string) {
107
- try {
108
- const userContext = await getUserContext();
109
- const usersTable = Users(userContext.userDataContext);
110
-
111
- const newContact = await usersTable.initRecord({
112
- userId: userId.trim(),
113
- name: "New User",
114
- publicKey: "",
115
- publicBoxKey: "",
116
- });
117
-
118
- await usersTable.save(newContact);
119
-
120
- // Set initial trust level
121
- await setUserTrustLevel(userId.trim(), TrustLevel.NewUser, userContext.userDataContext);
122
-
123
- // Navigate to the details screen for the new contact
124
- mainContentPath(`contacts/${userId.trim()}`);
125
- } catch (error) {
126
- console.error("Error creating contact:", error);
127
- }
128
- }
129
-
130
- async function handleSearchSubmit(evt: React.KeyboardEvent<HTMLInputElement>) {
131
- if (evt.key !== "Enter") return;
132
-
133
- const input = searchText.trim();
134
- if (!input) return;
135
-
136
- // Check if there are any existing results
137
- if (existingContacts.length === 0) {
138
- // No results found, check if it's a valid user ID
139
- if (isValidUserId(input)) {
140
- // Clear search and create new contact with the ID
141
- searchTextObs("");
142
- await createContact(input);
33
+ let disposed = false;
34
+ const subscriptions: Array<{ unsubscribe: () => void }> = [];
35
+ const refresh = async () => {
36
+ try {
37
+ const next = await loadPeople(userContext);
38
+ if (!disposed) {
39
+ setRows(next);
40
+ setError("");
41
+ }
42
+ } catch (err) {
43
+ if (!disposed) {
44
+ setError((err as Error).message || "Could not load people.");
45
+ }
143
46
  }
144
- }
145
- }
146
-
147
- function renderItems(contacts: IUserWithSource[]): JSX.Element[] {
148
- const elements: JSX.Element[] = [];
149
- let lastSource: string | null = null;
150
-
151
- contacts.forEach((contact, index) => {
152
- // Add horizontal separator when source changes
153
- if (lastSource !== contact.source) {
154
- elements.push(
155
- <div key={`separator-${index}`} className="my-3">
156
- {lastSource !== null && <hr className="border-secondary opacity-50" />}
157
- {contact.source === "userDataContext" && (
158
- <h5>Personal Contacts {lastSource !== null && "(not in this group)"}</h5>
159
- )}
160
- {contact.source === "currentDataContext" && <h5>Users in this group</h5>}
161
- {contact.source === "otherDataContexts" && <h5>Users from other groups</h5>}
162
- </div>,
163
- );
164
- lastSource = contact.source;
47
+ };
48
+ const personalUsers = Users(userContext.userDataContext);
49
+ const personalGroups = Groups(userContext.userDataContext);
50
+ subscriptions.push(personalUsers.dataChanged.subscribe(() => void refresh()));
51
+ subscriptions.push(
52
+ personalGroups.dataChanged.subscribe(() => setGroupRevision((value) => value + 1)),
53
+ );
54
+ void personalGroups.list({ disabled: { $ne: true } }).then((groups) => {
55
+ if (disposed) {
56
+ return;
57
+ }
58
+ for (const group of groups) {
59
+ const context = userContext.getDataContext(group.groupId);
60
+ subscriptions.push(Users(context).dataChanged.subscribe(() => void refresh()));
61
+ subscriptions.push(GroupMembers(context).dataChanged.subscribe(() => void refresh()));
165
62
  }
166
- elements.push(<ContactCard key={contact.userId} contact={contact} />);
63
+ void refresh();
167
64
  });
65
+ return () => {
66
+ disposed = true;
67
+ for (const subscription of subscriptions) {
68
+ subscription.unsubscribe();
69
+ }
70
+ };
71
+ }, [groupRevision, userContext]);
168
72
 
169
- return elements;
170
- }
73
+ const filteredRows = useMemo(() => {
74
+ const query = searchText.trim().toLocaleLowerCase();
75
+ if (!query) {
76
+ return rows ?? [];
77
+ }
78
+ return (rows ?? []).filter(
79
+ (row) =>
80
+ row.user.name.toLocaleLowerCase().includes(query) ||
81
+ row.sharedGroups.some((group) => group.name.toLocaleLowerCase().includes(query)),
82
+ );
83
+ }, [rows, searchText]);
84
+ const duplicateNames = useMemo(() => {
85
+ const counts = new Map<string, number>();
86
+ for (const row of rows ?? []) {
87
+ const key = row.user.name.trim().toLocaleLowerCase();
88
+ counts.set(key, (counts.get(key) ?? 0) + 1);
89
+ }
90
+ return counts;
91
+ }, [rows]);
171
92
 
172
93
  return (
173
- <div className="container-fluid p-3">
94
+ <div className="container-xxl p-3">
174
95
  <div className="d-flex justify-content-between align-items-center mb-3">
175
- <h4>
176
- <i className="bi-person-fill-check me-2" />
177
- Contacts
178
- </h4>
179
- <div className="d-flex gap-2">
180
- <a href={`#${ACCEPT_INVITE_PATH}`} className="btn btn-outline-primary btn-sm">
181
- <i className="bi-qr-code-scan me-1" />
182
- Accept invite
183
- </a>
184
- <button
185
- type="button"
186
- className="btn btn-primary btn-sm"
187
- onClick={() => setInviting(true)}
188
- disabled={inviting}
189
- >
190
- <i className="bi-person-plus me-1" />
191
- Invite
192
- </button>
96
+ <div>
97
+ <h4 className="mb-1">
98
+ <i className="bi-person-fill-check me-2" />
99
+ People
100
+ </h4>
101
+ <div className="small text-muted">Contacts and people you know through groups</div>
193
102
  </div>
103
+ <a href={`#${IDENTITY_PEOPLE_SHARE_PATH}`} className="btn btn-outline-primary btn-sm">
104
+ <i className="bi bi-qr-code me-1" />
105
+ Share my profile
106
+ </a>
194
107
  </div>
195
108
 
196
- {inviting ? <InviteContactPanel onClose={() => setInviting(false)} /> : null}
197
- <PendingInvitesList type="contact" hideWhenEmpty title="Pending contact invites" />
109
+ {props.action ? (
110
+ <div className="mb-3">
111
+ <div className="d-flex justify-content-end mb-2">
112
+ <a href={`#${IDENTITY_PEOPLE_PATH}`} className="btn btn-link btn-sm">
113
+ Close
114
+ </a>
115
+ </div>
116
+ {props.action === "share" ? (
117
+ <ProfileInviteSection />
118
+ ) : (
119
+ <InviteContactPanel onClose={() => mainContentPath(IDENTITY_PEOPLE_PATH)} />
120
+ )}
121
+ </div>
122
+ ) : null}
123
+ <PendingWorkLink type="contact" />
198
124
 
199
- <div className="input-group mb-3">
200
- <Input
201
- value={searchTextObs}
202
- className="form-control"
203
- placeholder="Search or create contacts"
204
- autoFocus={true}
205
- onKeyUp={handleSearchSubmit}
125
+ <div className="position-relative mb-3">
126
+ <i
127
+ className="bi bi-search position-absolute text-muted"
128
+ style={{ left: 12, top: "50%", transform: "translateY(-50%)" }}
206
129
  />
207
- </div>
208
-
209
- <div className="peers-list-container">
210
- <LazyList
211
- resetTrigger={searchText}
212
- loadMore={loadMore}
213
- scrollThreshold={0.6}
214
- renderItems={renderItems}
215
- loadingIndicator={
216
- <div className="d-flex justify-content-center" style={{ height: 200 }}>
217
- <LoadingIndicator />
218
- </div>
219
- }
220
- endOfList={
221
- <div className="d-flex justify-content-center" style={{ height: 200 }}>
222
- {/* End of contacts */}
223
- </div>
224
- }
130
+ <input
131
+ type="search"
132
+ className="form-control ps-5"
133
+ value={searchText}
134
+ onChange={(event) => setSearchText(event.target.value)}
135
+ placeholder="Search people or shared groups"
136
+ aria-label="Search people"
225
137
  />
226
138
  </div>
139
+
140
+ {error ? <div className="alert alert-danger">{error}</div> : null}
141
+ {!rows && !error ? <LoadingIndicator /> : null}
142
+ {rows && filteredRows.length === 0 ? (
143
+ <div className="border rounded p-4 text-center">
144
+ <i className="bi bi-people fs-2 text-muted" />
145
+ <h6 className="mt-2">{searchText ? "No matching people" : "No people yet"}</h6>
146
+ <p className="small text-muted mb-0">
147
+ {searchText
148
+ ? "Try another name or group."
149
+ : "Invite someone or join a group to start building your network."}
150
+ </p>
151
+ </div>
152
+ ) : null}
153
+ {filteredRows.length > 0 ? (
154
+ <div className="list-group">
155
+ {filteredRows.map((row) => (
156
+ <PersonRow
157
+ key={row.user.userId}
158
+ row={row}
159
+ duplicateName={
160
+ (duplicateNames.get(row.user.name.trim().toLocaleLowerCase()) ?? 0) > 1
161
+ }
162
+ />
163
+ ))}
164
+ </div>
165
+ ) : null}
227
166
  </div>
228
167
  );
229
168
  }
230
169
 
231
- interface ContactCardProps {
232
- contact: IUserWithSource;
170
+ /** One person plus the relationship context shown in the directory. */
171
+ export interface IPersonSummary {
172
+ /** Signed user profile. */
173
+ user: IUser;
174
+ /** Whether the person is saved in the personal context. */
175
+ isContact: boolean;
176
+ /** Personal trust assignment when this is a contact. */
177
+ trustLevel?: TrustLevel;
178
+ /** Enabled groups both users belong to. */
179
+ sharedGroups: Array<{ groupId: string; name: string }>;
233
180
  }
234
181
 
235
- const ContactCard = ({ contact }: ContactCardProps) => {
236
- const getSourceLabel = (source: string) => {
237
- switch (source) {
238
- case "userDataContext":
239
- return "Personal";
240
- case "currentDataContext":
241
- return "Current Context";
242
- case "otherDataContexts":
243
- return "Groups";
244
- default:
245
- return source;
182
+ async function loadPeople(userContext: UserContext): Promise<IPersonSummary[]> {
183
+ const personalUsers = await Users(userContext.userDataContext).list();
184
+ const personalById = new Map(personalUsers.map((user) => [user.userId, user]));
185
+ const summaries = new Map<string, IPersonSummary>();
186
+ for (const user of personalUsers) {
187
+ if (user.userId === userContext.userId) {
188
+ continue;
246
189
  }
247
- };
190
+ summaries.set(user.userId, {
191
+ user,
192
+ isContact: true,
193
+ trustLevel: await getUserTrustLevel(userContext.userDataContext, user.userId),
194
+ sharedGroups: [],
195
+ });
196
+ }
248
197
 
249
- const getSourceIcon = (source: string) => {
250
- switch (source) {
251
- case "userDataContext":
252
- return "bi-person-fill";
253
- case "currentDataContext":
254
- return "bi-house-fill";
255
- case "otherDataContexts":
256
- return "bi-people-fill";
257
- default:
258
- return "bi-question-circle";
198
+ const groups = await Groups(userContext.userDataContext).list({ disabled: { $ne: true } });
199
+ for (const group of groups) {
200
+ const context = userContext.getDataContext(group.groupId);
201
+ const members = await GroupMembers(context).list({ deleted: { $ne: true } });
202
+ for (const member of members) {
203
+ if (member.userId === userContext.userId) {
204
+ continue;
205
+ }
206
+ const existing = summaries.get(member.userId);
207
+ const user = personalById.get(member.userId) ?? (await Users(context).get(member.userId));
208
+ if (!user) {
209
+ continue;
210
+ }
211
+ const summary = existing ?? {
212
+ user,
213
+ isContact: false,
214
+ sharedGroups: [],
215
+ };
216
+ if (!summary.sharedGroups.some((entry) => entry.groupId === group.groupId)) {
217
+ summary.sharedGroups.push({ groupId: group.groupId, name: group.name });
218
+ }
219
+ summaries.set(member.userId, summary);
259
220
  }
260
- };
221
+ }
261
222
 
262
- return (
263
- <div className="list-group-item d-flex align-items-center justify-content-between py-2">
264
- <div className="d-flex align-items-center">
265
- <i className="bi-person-circle me-3" style={{ fontSize: "32px", color: "#6c757d" }} />
266
- <div>
267
- <strong>{contact.name}</strong>
268
- <div className="d-flex align-items-center">
269
- <small className="text-muted">{contact.userId}</small>
270
- <span className="mx-1 text-muted">•</span>
271
- <small className="text-info d-flex align-items-center">
272
- <i className={`${getSourceIcon(contact.source)} me-1`} />
273
- {getSourceLabel(contact.source)}
274
- </small>
275
- </div>
276
- </div>
277
- </div>
223
+ return [...summaries.values()].sort((left, right) =>
224
+ left.user.name.localeCompare(right.user.name),
225
+ );
226
+ }
278
227
 
279
- <div className="d-flex align-items-center">
280
- {<TrustLevelBadge level={contact.trustLevel || TrustLevel.Unknown} />}
281
- <a href={`#contacts/${contact.userId}`} className="btn btn-outline-primary btn-sm ms-2">
282
- <i className="bi-eye me-1" />
283
- View
284
- </a>
285
- </div>
286
- </div>
228
+ /** Compact, fully clickable row in the People directory. */
229
+ export function PersonRow(props: { row: IPersonSummary; duplicateName: boolean }) {
230
+ const { row } = props;
231
+ const groups = row.sharedGroups;
232
+ const relationship = row.isContact
233
+ ? groups.length > 0
234
+ ? `Contact · ${sharedGroupLabel(groups)}`
235
+ : "Contact"
236
+ : sharedGroupLabel(groups);
237
+ return (
238
+ <a
239
+ href={`#${IDENTITY_PEOPLE_PATH}/${row.user.userId}`}
240
+ className="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3"
241
+ >
242
+ <span
243
+ className="rounded-circle bg-body-secondary d-inline-flex align-items-center justify-content-center flex-shrink-0 fw-semibold"
244
+ style={{ width: 42, height: 42 }}
245
+ aria-hidden="true"
246
+ >
247
+ {initials(row.user.name)}
248
+ </span>
249
+ <span className="flex-grow-1 overflow-hidden">
250
+ <span className="d-flex align-items-center gap-2 flex-wrap">
251
+ <span className="fw-semibold text-truncate">{row.user.name || "Unnamed person"}</span>
252
+ {props.duplicateName ? (
253
+ <code className="small text-muted">…{row.user.userId.slice(-6)}</code>
254
+ ) : null}
255
+ </span>
256
+ <span className="small text-muted text-truncate d-block">{relationship}</span>
257
+ </span>
258
+ {row.isContact ? (
259
+ <TrustLevelBadge level={row.trustLevel ?? TrustLevel.Unknown} />
260
+ ) : (
261
+ <span className="badge text-bg-light border">Shared group</span>
262
+ )}
263
+ <i className="bi bi-chevron-right text-muted" aria-hidden="true" />
264
+ </a>
287
265
  );
288
- };
266
+ }
289
267
 
290
- registerInternalPeersUI({
291
- peersUIId: "00mfc4ql0lgqmjxrpvcd4ufk0",
292
- component: ContactList,
293
- routes: [
294
- {
295
- isMatch: (_props, context) => context.path === "contacts",
296
- uiCategory: "screen",
297
- priority: 2,
298
- },
299
- ],
300
- });
268
+ function sharedGroupLabel(groups: Array<{ name: string }>): string {
269
+ if (groups.length === 0) {
270
+ return "Known through a shared group";
271
+ }
272
+ if (groups.length === 1) {
273
+ return `Shared group: ${groups[0].name}`;
274
+ }
275
+ return `${groups.length} shared groups`;
276
+ }
277
+
278
+ function initials(name: string): string {
279
+ const parts = name.trim().split(/\s+/).filter(Boolean);
280
+ return (
281
+ parts
282
+ .slice(0, 2)
283
+ .map((part) => part[0]?.toLocaleUpperCase())
284
+ .join("") || "?"
285
+ );
286
+ }
@@ -1,6 +1,2 @@
1
- // Import all contact screen components to ensure they register their routes
2
- import "./contact-list";
3
- import "./contact-details";
4
-
5
1
  export * from "./contact-details";
6
2
  export * from "./contact-list";
@@ -1,4 +1,4 @@
1
- import { type DevicePairingState, rpcServerCalls } from "@peers-app/peers-sdk";
1
+ import type { DevicePairingState } from "@peers-app/peers-sdk";
2
2
  import { fireEvent, render, waitFor } from "@testing-library/react";
3
3
  import { DestinationDevicePairing, SourceDevicePairing } from "./device-pairing";
4
4
 
@@ -11,13 +11,9 @@ const mockRpc = {
11
11
  };
12
12
  let mockPairingState: DevicePairingState = { phase: "idle" };
13
13
 
14
- rpcServerCalls.devicePairingStartSource =
15
- mockRpc.startSource as typeof rpcServerCalls.devicePairingStartSource;
16
- rpcServerCalls.devicePairingStartDestination =
17
- mockRpc.startDestination as typeof rpcServerCalls.devicePairingStartDestination;
18
- rpcServerCalls.devicePairingApprove = mockRpc.approve as typeof rpcServerCalls.devicePairingApprove;
19
- rpcServerCalls.devicePairingReject = mockRpc.reject as typeof rpcServerCalls.devicePairingReject;
20
- rpcServerCalls.devicePairingCancel = mockRpc.cancel as typeof rpcServerCalls.devicePairingCancel;
14
+ jest.mock("./identity-ipc", () => ({
15
+ identityTools: async () => mockRpc,
16
+ }));
21
17
 
22
18
  jest.mock("./use-device-pairing-state", () => ({
23
19
  useDevicePairingState: () => mockPairingState,
@@ -39,7 +35,9 @@ describe("device pairing UI lifecycle", () => {
39
35
  );
40
36
  const view = render(<DestinationDevicePairing onBack={jest.fn()} />);
41
37
 
42
- expect(mockRpc.startDestination).toHaveBeenCalledWith({ deviceType: "browser" });
38
+ await waitFor(() =>
39
+ expect(mockRpc.startDestination).toHaveBeenCalledWith({ deviceType: "browser" }),
40
+ );
43
41
  view.unmount();
44
42
  resolveStart({
45
43
  sessionId: "00mh0wlipkdbeaw8tests0001",