@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
@@ -8,56 +8,7 @@ const loading_indicator_1 = require("../../components/loading-indicator");
8
8
  const tooltip_1 = require("../../components/tooltip");
9
9
  const hooks_1 = require("../../hooks");
10
10
  const peers_services_registration_1 = require("../../peers-services-registration");
11
- const invite_share_1 = require("../invites/invite-share");
12
- const invites_ipc_1 = require("../invites/invites-ipc");
13
- const PROFILE_QR_INFO = `### Your profile QR
14
-
15
- Anyone who scans this (or opens the link) can send you a contact request. Requests show up under **Contacts → Pending** and nothing happens until you confirm.
16
-
17
- ### Regenerate
18
-
19
- Regenerating invalidates this QR **and every other invite link you have issued**. Use it if the QR leaked somewhere you did not intend.`;
20
- /** Reusable profile invite with a regenerate control that bumps the invite epoch. */
21
- function ProfileInviteSection() {
22
- const [invite, setInvite] = (0, react_1.useState)(undefined);
23
- const [busy, setBusy] = (0, react_1.useState)(false);
24
- const [error, setError] = (0, react_1.useState)(null);
25
- (0, react_1.useEffect)(() => {
26
- let cancelled = false;
27
- void (0, invites_ipc_1.inviteTools)()
28
- .then((tools) => tools.createContactInvite({ profile: true }))
29
- .then(({ invite: created }) => {
30
- if (!cancelled)
31
- setInvite(created);
32
- })
33
- .catch((err) => {
34
- if (!cancelled)
35
- setError(err.message || "Could not load your profile QR.");
36
- });
37
- return () => {
38
- cancelled = true;
39
- };
40
- }, []);
41
- const regenerate = async () => {
42
- if (!confirm("Regenerate your profile QR? Every invite link you have issued so far will stop working.")) {
43
- return;
44
- }
45
- setBusy(true);
46
- setError(null);
47
- try {
48
- const tools = await (0, invites_ipc_1.inviteTools)();
49
- const { invite: created } = await tools.regenerateProfileInvite();
50
- setInvite(created);
51
- }
52
- catch (err) {
53
- setError(err.message || "Could not regenerate.");
54
- }
55
- finally {
56
- setBusy(false);
57
- }
58
- };
59
- return ((0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsxs)("h5", { className: "card-title d-flex align-items-center gap-2", children: ["Profile QR", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: PROFILE_QR_INFO })] }), error ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger py-2", children: error }) : null, invite ? ((0, jsx_runtime_1.jsx)(invite_share_1.InviteShare, { invite: invite, description: "Let people connect with you by scanning this or opening the link. You confirm each request.", shareText: "Connect with me on Peers:" })) : !error ? ((0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {})) : null, (0, jsx_runtime_1.jsx)("div", { className: "text-center mt-3", children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger btn-sm", onClick: () => void regenerate(), disabled: busy || !invite, children: busy ? "Regenerating…" : "Regenerate (revokes all invites)" }) })] }) }));
60
- }
11
+ const identity_ipc_1 = require("../device-pairing/identity-ipc");
61
12
  const INFO_TEXT = `### What does registering do?
62
13
 
63
14
  Registering your account links your **public key** with the Peers cloud service. This enables cloud-powered features like:
@@ -74,8 +25,11 @@ Your **secret key never leaves your device**. Only your public key is shared wit
74
25
  ### Do I have to register?
75
26
 
76
27
  No. Peers works fully in local/peer-to-peer mode without registration. Cloud features simply won't be available until you register.`;
28
+ /** Identity profile, public identifiers, device shortcuts, and service registration. */
77
29
  function AccountScreen() {
78
30
  const [registrationStatus] = (0, hooks_1.useObservable)(peers_services_registration_1.registrationStatusPvar);
31
+ const [deviceId] = (0, hooks_1.useObservable)(peers_sdk_1.thisDeviceId);
32
+ const me = (0, hooks_1.usePromise)(peers_sdk_1.getMe);
79
33
  const [isRegistering, setIsRegistering] = (0, react_1.useState)(false);
80
34
  const [error, setError] = (0, react_1.useState)(null);
81
35
  const [success, setSuccess] = (0, react_1.useState)(null);
@@ -85,8 +39,8 @@ function AccountScreen() {
85
39
  setError(null);
86
40
  setSuccess(null);
87
41
  try {
88
- const token = await peers_sdk_1.rpcServerCalls.registerWithPeersServices();
89
- (0, peers_services_registration_1.persistPeersServicesRegistration)(token);
42
+ const { token } = await (await (0, identity_ipc_1.identityTools)()).registerWithPeersServices();
43
+ await (0, peers_services_registration_1.persistPeersServicesRegistration)(token);
90
44
  setSuccess("Account registered successfully.");
91
45
  }
92
46
  catch (err) {
@@ -96,9 +50,31 @@ function AccountScreen() {
96
50
  setIsRegistering(false);
97
51
  }
98
52
  };
99
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-3", style: { maxWidth: 600 }, children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-3 d-flex align-items-center gap-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-circle" }), "Account"] }), error && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger alert-dismissible", role: "alert", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-exclamation-triangle me-2" }), error, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn-close", onClick: () => setError(null), "aria-label": "Close" })] })), success && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-success alert-dismissible", role: "alert", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-check-circle me-2" }), success, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn-close", onClick: () => setSuccess(null), "aria-label": "Close" })] })), (0, jsx_runtime_1.jsx)(ProfileInviteSection, {}), (0, jsx_runtime_1.jsx)("div", { className: "card", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsxs)("h5", { className: "card-title d-flex align-items-center gap-2", children: ["Peers Services Registration", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: INFO_TEXT })] }), isRegistered ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-2 mb-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-check-circle-fill text-success", style: { fontSize: "1.2em" } }), (0, jsx_runtime_1.jsx)("span", { children: "Registered" })] }), (0, jsx_runtime_1.jsxs)("small", { className: "text-muted d-block mb-3", children: ["Registered on", " ", new Date(registrationStatus).toLocaleDateString(undefined, {
53
+ if (!me) {
54
+ return (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {});
55
+ }
56
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-1 d-flex align-items-center gap-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-circle" }), "Account"] }), (0, jsx_runtime_1.jsx)("div", { className: "small text-muted", children: "Your public identity and account-level services" })] }), error && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger alert-dismissible", role: "alert", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-exclamation-triangle me-2" }), error, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn-close", onClick: () => setError(null), "aria-label": "Close" })] })), success && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-success alert-dismissible", role: "alert", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-check-circle me-2" }), success, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn-close", onClick: () => setSuccess(null), "aria-label": "Close" })] })), (0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body d-flex align-items-center gap-3 flex-wrap", children: [(0, jsx_runtime_1.jsx)("span", { className: "rounded-circle bg-primary-subtle text-primary-emphasis d-inline-flex align-items-center justify-content-center fs-4 fw-semibold", style: { width: 58, height: 58 }, "aria-hidden": "true", children: profileInitials(me.name) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-grow-1", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title mb-1", children: me.name || "Unnamed user" }), (0, jsx_runtime_1.jsx)("div", { className: "small text-muted", children: "Peers identity" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsx)("a", { href: "#settings", className: "btn btn-outline-secondary btn-sm", children: "Edit display name" }), (0, jsx_runtime_1.jsx)("a", { href: "#identity/devices", className: "btn btn-outline-primary btn-sm", children: "Manage devices" }), (0, jsx_runtime_1.jsx)("a", { href: "#identity/devices/pair", className: "btn btn-primary btn-sm", children: "Add device" })] })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Technical identity" }), (0, jsx_runtime_1.jsx)("p", { className: "small text-muted", children: "Share these values only when diagnosing identity or connection problems." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "User ID", value: me.userId, help: "Your stable identifier across Peers." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Signing key", value: me.publicKey, help: "Other people use this public key to verify your signatures." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Encryption key", value: me.publicBoxKey, help: "Other people use this public key to encrypt data for you." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Current device ID", value: deviceId || "Not available", help: "Identifies this installation on Peers networks." })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "card", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsxs)("h5", { className: "card-title d-flex align-items-center gap-2", children: ["Peers Services Registration", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: INFO_TEXT })] }), isRegistered ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-2 mb-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-check-circle-fill text-success", style: { fontSize: "1.2em" } }), (0, jsx_runtime_1.jsx)("span", { children: "Registered" })] }), (0, jsx_runtime_1.jsxs)("small", { className: "text-muted d-block mb-3", children: ["Registered on", " ", new Date(registrationStatus).toLocaleDateString(undefined, {
100
57
  year: "numeric",
101
58
  month: "long",
102
59
  day: "numeric",
103
60
  })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary btn-sm", onClick: handleRegister, disabled: isRegistering, children: isRegistering ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-2" }), "Refreshing..."] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-arrow-clockwise me-2" }), "Refresh Token"] })) })] })) : ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-3", children: "Register your account to enable cloud-powered features like the AI assistant." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", onClick: handleRegister, disabled: isRegistering, children: isRegistering ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-2" }), "Registering..."] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-cloud-upload me-2" }), "Register Account"] })) })] }))] }) })] }));
104
61
  }
62
+ function TechnicalValue(props) {
63
+ const [copied, setCopied] = (0, react_1.useState)(false);
64
+ const copy = async () => {
65
+ if (await (0, peers_sdk_1.copyToClipboard)(props.value)) {
66
+ setCopied(true);
67
+ window.setTimeout(() => setCopied(false), 1500);
68
+ }
69
+ };
70
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "border-top py-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-2 mb-1", children: [(0, jsx_runtime_1.jsx)("strong", { className: "small", children: props.label }), (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: props.help }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-link btn-sm ms-auto py-0", onClick: () => void copy(), "aria-label": `Copy ${props.label}`, children: [(0, jsx_runtime_1.jsx)("i", { className: `bi ${copied ? "bi-check-lg" : "bi-copy"} me-1` }), copied ? "Copied" : "Copy"] })] }), (0, jsx_runtime_1.jsx)("code", { className: "small text-break user-select-all", children: props.value })] }));
71
+ }
72
+ function profileInitials(name) {
73
+ return (name
74
+ .trim()
75
+ .split(/\s+/)
76
+ .filter(Boolean)
77
+ .slice(0, 2)
78
+ .map((part) => part[0]?.toLocaleUpperCase())
79
+ .join("") || "?");
80
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const react_1 = require("@testing-library/react");
5
+ const account_screen_1 = require("./account-screen");
6
+ jest.mock("../../components/tooltip", () => ({
7
+ Tooltip: () => null,
8
+ }));
9
+ jest.mock("../../hooks", () => ({
10
+ useObservable: (value) => value === "device-observable" ? ["device0000000000000000001"] : [undefined],
11
+ usePromise: () => ({
12
+ userId: "user00000000000000000001",
13
+ name: "Taylor Example",
14
+ publicKey: "public-signing-key",
15
+ publicBoxKey: "public-encryption-key",
16
+ }),
17
+ }));
18
+ jest.mock("../../peers-services-registration", () => ({
19
+ persistPeersServicesRegistration: jest.fn(),
20
+ registrationStatusPvar: "registration-observable",
21
+ }));
22
+ jest.mock("../device-pairing/identity-ipc", () => ({
23
+ identityTools: jest.fn(),
24
+ }));
25
+ jest.mock("@peers-app/peers-sdk", () => {
26
+ const actual = jest.requireActual("@peers-app/peers-sdk");
27
+ return {
28
+ ...actual,
29
+ copyToClipboard: jest.fn(async () => true),
30
+ getMe: jest.fn(),
31
+ thisDeviceId: "device-observable",
32
+ };
33
+ });
34
+ describe("AccountScreen", () => {
35
+ it("shows profile actions and copyable identity diagnostics", () => {
36
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(account_screen_1.AccountScreen, {}));
37
+ expect(react_1.screen.getByText("Taylor Example")).toBeInTheDocument();
38
+ expect(react_1.screen.getByText("user00000000000000000001")).toBeInTheDocument();
39
+ expect(react_1.screen.getByText("public-signing-key")).toBeInTheDocument();
40
+ expect(react_1.screen.getByText("public-encryption-key")).toBeInTheDocument();
41
+ expect(react_1.screen.getByText("device0000000000000000001")).toBeInTheDocument();
42
+ expect(react_1.screen.getByRole("link", { name: "Edit display name" })).toHaveAttribute("href", "#settings");
43
+ expect(react_1.screen.getByRole("link", { name: "Manage devices" })).toHaveAttribute("href", "#identity/devices");
44
+ expect(react_1.screen.getByRole("link", { name: "Add device" })).toHaveAttribute("href", "#identity/devices/pair");
45
+ expect(react_1.screen.getByRole("button", { name: "Copy User ID" })).toBeInTheDocument();
46
+ });
47
+ });
@@ -9,8 +9,6 @@ const save_button_1 = require("../../components/save-button");
9
9
  const tabs_1 = require("../../components/tabs");
10
10
  const trust_level_dropdown_1 = require("../../components/trust-level-dropdown");
11
11
  const hooks_1 = require("../../hooks");
12
- const tabs_state_1 = require("../../tabs-layout/tabs-state");
13
- const ui_loader_1 = require("../../ui-router/ui-loader");
14
12
  /** Edit a contact's signed profile fields and personal-context trust assignment. */
15
13
  const ContactDetails = (props) => {
16
14
  const contact = (0, hooks_1.usePromise)(async () => {
@@ -19,16 +17,15 @@ const ContactDetails = (props) => {
19
17
  if (!contact) {
20
18
  throw new Error("Contact not found");
21
19
  }
22
- (0, tabs_state_1.updateActiveTabTitle)(contact.name || "Contact");
23
20
  return (0, peers_sdk_1.Users)(userContext.userDataContext).initDoc(contact);
24
21
  }, undefined, [props.userId]);
25
22
  if (!contact) {
26
23
  return (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {});
27
24
  }
28
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex", children: [(0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("h4", { children: (0, jsx_runtime_1.jsx)("i", { className: "bi-person-fill-check me-2" }) }) }), (0, jsx_runtime_1.jsx)("div", { className: "flex-grow-1", children: (0, jsx_runtime_1.jsx)("h4", { children: (0, jsx_runtime_1.jsx)(input_1.Input, { className: "border border-0", style: { width: "100%", outline: "none", backgroundColor: "transparent" }, value: contact.qs.name }, contact.userId) }) }), (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(save_button_1.SaveButton, { doc: contact }, contact.userId) })] }), (0, jsx_runtime_1.jsx)(tabs_1.Tabs, { tabs: [
25
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex", children: [(0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("h4", { children: (0, jsx_runtime_1.jsx)("i", { className: "bi-person-fill-check me-2" }) }) }), (0, jsx_runtime_1.jsx)("div", { className: "flex-grow-1", children: (0, jsx_runtime_1.jsx)("h4", { children: (0, jsx_runtime_1.jsx)(input_1.Input, { className: "border border-0", style: { width: "100%", outline: "none", backgroundColor: "transparent" }, value: contact.qs.name }, contact.userId) }) }), (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(save_button_1.SaveButton, { doc: contact }, contact.userId) })] }), (0, jsx_runtime_1.jsx)(tabs_1.Tabs, { tabs: [
29
26
  {
30
27
  name: "Info",
31
- content: ((0, jsx_runtime_1.jsx)(tabs_1.ScreenTabBody, { children: (0, jsx_runtime_1.jsx)(ContactInfo, { contact: contact }) })),
28
+ content: ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(ContactInfo, { contact: contact }) })),
32
29
  },
33
30
  ] }, contact.userId)] }));
34
31
  };
@@ -37,21 +34,3 @@ const ContactInfo = (props) => {
37
34
  const { contact } = props;
38
35
  return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { className: "mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex-grow-1", children: [(0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block mb-1", children: "Name" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: contact.qs.name, className: "form-control mb-3 ps-2", placeholder: "Contact name", title: "Contact name" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "border-start ps-3", children: [(0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block mb-1", children: "User ID" }), (0, jsx_runtime_1.jsx)("code", { className: "small", children: contact.userId })] }), (0, jsx_runtime_1.jsxs)("div", { className: "border-start ps-3", children: [(0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block mb-1", children: "Trust Level" }), (0, jsx_runtime_1.jsx)(trust_level_dropdown_1.TrustLevelDropdown, { userId: contact.userId }), (0, jsx_runtime_1.jsx)("small", { className: "text-muted d-block mt-1", children: "Self grants this user full remote contract access." })] })] }) }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("small", { children: "Public Key:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: contact.qs.publicKey, className: "form-control mb-3 p-0 ps-2", placeholder: "Public key", title: "Public key" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("small", { children: "Public Box Key:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: contact.qs.publicBoxKey, className: "form-control mb-3 p-0 ps-2", placeholder: "Public box key", title: "Public box key" })] })] }));
39
36
  };
40
- (0, ui_loader_1.registerInternalPeersUI)({
41
- peersUIId: "00contact00details00component",
42
- component: exports.ContactDetails,
43
- routes: [
44
- {
45
- isMatch: (props, context) => {
46
- const match = context.path.match(/^contacts\/([a-zA-Z0-9]+)$/);
47
- if (match) {
48
- props.userId = match[1];
49
- return true;
50
- }
51
- return false;
52
- },
53
- uiCategory: "screen",
54
- priority: 2,
55
- },
56
- ],
57
- });
@@ -1 +1,24 @@
1
- export declare function ContactList(): import("react/jsx-runtime").JSX.Element;
1
+ import { type IUser, TrustLevel } from "@peers-app/peers-sdk";
2
+ /** People directory and route-backed invite/profile actions. */
3
+ export declare function ContactList(props?: {
4
+ action?: "share" | "invite";
5
+ }): import("react/jsx-runtime").JSX.Element;
6
+ /** One person plus the relationship context shown in the directory. */
7
+ export interface IPersonSummary {
8
+ /** Signed user profile. */
9
+ user: IUser;
10
+ /** Whether the person is saved in the personal context. */
11
+ isContact: boolean;
12
+ /** Personal trust assignment when this is a contact. */
13
+ trustLevel?: TrustLevel;
14
+ /** Enabled groups both users belong to. */
15
+ sharedGroups: Array<{
16
+ groupId: string;
17
+ name: string;
18
+ }>;
19
+ }
20
+ /** Compact, fully clickable row in the People directory. */
21
+ export declare function PersonRow(props: {
22
+ row: IPersonSummary;
23
+ duplicateName: boolean;
24
+ }): import("react/jsx-runtime").JSX.Element;
@@ -1,167 +1,152 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ContactList = ContactList;
4
+ exports.PersonRow = PersonRow;
4
5
  const jsx_runtime_1 = require("react/jsx-runtime");
5
6
  const peers_sdk_1 = require("@peers-app/peers-sdk");
6
7
  const react_1 = require("react");
7
- const input_1 = require("../../components/input");
8
- const lazy_list_1 = require("../../components/lazy-list");
9
8
  const loading_indicator_1 = require("../../components/loading-indicator");
10
9
  const trust_level_badge_1 = require("../../components/trust-level-badge");
11
10
  const globals_1 = require("../../globals");
12
11
  const hooks_1 = require("../../hooks");
13
- const ui_loader_1 = require("../../ui-router/ui-loader");
14
- const invite_capture_1 = require("../invites/invite-capture");
15
- const invite_share_1 = require("../invites/invite-share");
16
- const invites_ipc_1 = require("../invites/invites-ipc");
17
- const pending_invites_list_1 = require("../invites/pending-invites-list");
18
- /** Issues a one-off contact invite and shows it as QR/link until dismissed. */
19
- function InviteContactPanel({ onClose }) {
20
- const [invite, setInvite] = (0, react_1.useState)(undefined);
12
+ const identity_routes_1 = require("../identity/identity-routes");
13
+ const invite_contact_panel_1 = require("../identity/invite-contact-panel");
14
+ const profile_invite_1 = require("../identity/profile-invite");
15
+ const activity_view_1 = require("../invites/activity-view");
16
+ /** People directory and route-backed invite/profile actions. */
17
+ function ContactList(props = {}) {
18
+ const userContext = (0, hooks_1.usePromise)(peers_sdk_1.getUserContext);
19
+ const [rows, setRows] = (0, react_1.useState)();
21
20
  const [error, setError] = (0, react_1.useState)("");
21
+ const [searchText, setSearchText] = (0, react_1.useState)("");
22
+ const [groupRevision, setGroupRevision] = (0, react_1.useState)(0);
22
23
  (0, react_1.useEffect)(() => {
23
- let cancelled = false;
24
- void (0, invites_ipc_1.inviteTools)()
25
- .then((tools) => tools.createContactInvite({}))
26
- .then(({ invite: created }) => {
27
- if (!cancelled)
28
- setInvite(created);
29
- })
30
- .catch((err) => {
31
- if (!cancelled)
32
- setError(err.message || "Could not create an invite.");
24
+ if (!userContext) {
25
+ return;
26
+ }
27
+ let disposed = false;
28
+ const subscriptions = [];
29
+ const refresh = async () => {
30
+ try {
31
+ const next = await loadPeople(userContext);
32
+ if (!disposed) {
33
+ setRows(next);
34
+ setError("");
35
+ }
36
+ }
37
+ catch (err) {
38
+ if (!disposed) {
39
+ setError(err.message || "Could not load people.");
40
+ }
41
+ }
42
+ };
43
+ const personalUsers = (0, peers_sdk_1.Users)(userContext.userDataContext);
44
+ const personalGroups = (0, peers_sdk_1.Groups)(userContext.userDataContext);
45
+ subscriptions.push(personalUsers.dataChanged.subscribe(() => void refresh()));
46
+ subscriptions.push(personalGroups.dataChanged.subscribe(() => setGroupRevision((value) => value + 1)));
47
+ void personalGroups.list({ disabled: { $ne: true } }).then((groups) => {
48
+ if (disposed) {
49
+ return;
50
+ }
51
+ for (const group of groups) {
52
+ const context = userContext.getDataContext(group.groupId);
53
+ subscriptions.push((0, peers_sdk_1.Users)(context).dataChanged.subscribe(() => void refresh()));
54
+ subscriptions.push((0, peers_sdk_1.GroupMembers)(context).dataChanged.subscribe(() => void refresh()));
55
+ }
56
+ void refresh();
33
57
  });
34
58
  return () => {
35
- cancelled = true;
59
+ disposed = true;
60
+ for (const subscription of subscriptions) {
61
+ subscription.unsubscribe();
62
+ }
36
63
  };
37
- }, []);
38
- return ((0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h6", { className: "card-title", children: "Invite a contact" }), error ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger py-2", children: error }) : null, invite ? ((0, jsx_runtime_1.jsx)(invite_share_1.InviteShare, { invite: invite, description: "Send this link or show the QR. When they accept, you are connected \u2014 no need to be online at the same time.", shareText: "Connect with me on Peers:", onDone: onClose })) : !error ? ((0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {})) : null] }) }));
39
- }
40
- function ContactList() {
41
- const [inviting, setInviting] = (0, react_1.useState)(false);
42
- const [searchTextObs] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)(""));
43
- const [searchText] = (0, hooks_1.useObservable)(searchTextObs);
44
- const [cursorObs] = (0, react_1.useState)(() => (0, peers_sdk_1.observable)());
45
- const [existingContacts, setExistingContacts] = (0, react_1.useState)([]);
46
- const newCursor = (0, react_1.useCallback)(async () => {
47
- const cursor = await (0, peers_sdk_1.usersCursor)({ name: { $matchWords: searchText } });
48
- cursorObs(cursor);
49
- return cursor;
50
- }, [searchText, cursorObs]);
51
- (0, react_1.useEffect)(() => {
52
- void newCursor();
53
- setExistingContacts([]); // Reset existing contacts when search changes
54
- }, [newCursor]);
55
- async function loadMore(existing) {
56
- const moreMatches = [];
57
- const cursor = cursorObs() || (await newCursor());
58
- for await (const nextContact of cursor) {
59
- if (existing.some((e) => e.userId === nextContact.userId))
60
- continue;
61
- if (moreMatches.some((e) => e.userId === nextContact.userId))
62
- continue;
63
- moreMatches.push(nextContact);
64
- if (searchText.length && moreMatches.length > 5)
65
- break;
66
- if (moreMatches.length >= 10)
67
- break;
64
+ }, [groupRevision, userContext]);
65
+ const filteredRows = (0, react_1.useMemo)(() => {
66
+ const query = searchText.trim().toLocaleLowerCase();
67
+ if (!query) {
68
+ return rows ?? [];
68
69
  }
69
- if (moreMatches.length === 0) {
70
- cursorObs(undefined);
70
+ return (rows ?? []).filter((row) => row.user.name.toLocaleLowerCase().includes(query) ||
71
+ row.sharedGroups.some((group) => group.name.toLocaleLowerCase().includes(query)));
72
+ }, [rows, searchText]);
73
+ const duplicateNames = (0, react_1.useMemo)(() => {
74
+ const counts = new Map();
75
+ for (const row of rows ?? []) {
76
+ const key = row.user.name.trim().toLocaleLowerCase();
77
+ counts.set(key, (counts.get(key) ?? 0) + 1);
71
78
  }
72
- setExistingContacts(existing.concat(moreMatches)); // Track all loaded contacts
73
- return moreMatches;
74
- }
75
- function isValidUserId(id) {
76
- // Check if it's a valid ID format (alphanumeric, typically 25 characters)
77
- return /^[a-zA-Z0-9]{20,30}$/.test(id);
78
- }
79
- async function createContact(userId) {
80
- try {
81
- const userContext = await (0, peers_sdk_1.getUserContext)();
82
- const usersTable = (0, peers_sdk_1.Users)(userContext.userDataContext);
83
- const newContact = await usersTable.initRecord({
84
- userId: userId.trim(),
85
- name: "New User",
86
- publicKey: "",
87
- publicBoxKey: "",
88
- });
89
- await usersTable.save(newContact);
90
- // Set initial trust level
91
- await (0, peers_sdk_1.setUserTrustLevel)(userId.trim(), peers_sdk_1.TrustLevel.NewUser, userContext.userDataContext);
92
- // Navigate to the details screen for the new contact
93
- (0, globals_1.mainContentPath)(`contacts/${userId.trim()}`);
94
- }
95
- catch (error) {
96
- console.error("Error creating contact:", error);
79
+ return counts;
80
+ }, [rows]);
81
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex justify-content-between align-items-center mb-3", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-1", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-person-fill-check me-2" }), "People"] }), (0, jsx_runtime_1.jsx)("div", { className: "small text-muted", children: "Contacts and people you know through groups" })] }), (0, jsx_runtime_1.jsxs)("a", { href: `#${identity_routes_1.IDENTITY_PEOPLE_SHARE_PATH}`, className: "btn btn-outline-primary btn-sm", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-qr-code me-1" }), "Share my profile"] })] }), props.action ? ((0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-end mb-2", children: (0, jsx_runtime_1.jsx)("a", { href: `#${identity_routes_1.IDENTITY_PEOPLE_PATH}`, className: "btn btn-link btn-sm", children: "Close" }) }), props.action === "share" ? ((0, jsx_runtime_1.jsx)(profile_invite_1.ProfileInviteSection, {})) : ((0, jsx_runtime_1.jsx)(invite_contact_panel_1.InviteContactPanel, { onClose: () => (0, globals_1.mainContentPath)(identity_routes_1.IDENTITY_PEOPLE_PATH) }))] })) : null, (0, jsx_runtime_1.jsx)(activity_view_1.PendingWorkLink, { type: "contact" }), (0, jsx_runtime_1.jsxs)("div", { className: "position-relative mb-3", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-search position-absolute text-muted", style: { left: 12, top: "50%", transform: "translateY(-50%)" } }), (0, jsx_runtime_1.jsx)("input", { type: "search", className: "form-control ps-5", value: searchText, onChange: (event) => setSearchText(event.target.value), placeholder: "Search people or shared groups", "aria-label": "Search people" })] }), error ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", children: error }) : null, !rows && !error ? (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}) : null, rows && filteredRows.length === 0 ? ((0, jsx_runtime_1.jsxs)("div", { className: "border rounded p-4 text-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-people fs-2 text-muted" }), (0, jsx_runtime_1.jsx)("h6", { className: "mt-2", children: searchText ? "No matching people" : "No people yet" }), (0, jsx_runtime_1.jsx)("p", { className: "small text-muted mb-0", children: searchText
82
+ ? "Try another name or group."
83
+ : "Invite someone or join a group to start building your network." })] })) : null, filteredRows.length > 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "list-group", children: filteredRows.map((row) => ((0, jsx_runtime_1.jsx)(PersonRow, { row: row, duplicateName: (duplicateNames.get(row.user.name.trim().toLocaleLowerCase()) ?? 0) > 1 }, row.user.userId))) })) : null] }));
84
+ }
85
+ async function loadPeople(userContext) {
86
+ const personalUsers = await (0, peers_sdk_1.Users)(userContext.userDataContext).list();
87
+ const personalById = new Map(personalUsers.map((user) => [user.userId, user]));
88
+ const summaries = new Map();
89
+ for (const user of personalUsers) {
90
+ if (user.userId === userContext.userId) {
91
+ continue;
97
92
  }
93
+ summaries.set(user.userId, {
94
+ user,
95
+ isContact: true,
96
+ trustLevel: await (0, peers_sdk_1.getUserTrustLevel)(userContext.userDataContext, user.userId),
97
+ sharedGroups: [],
98
+ });
98
99
  }
99
- async function handleSearchSubmit(evt) {
100
- if (evt.key !== "Enter")
101
- return;
102
- const input = searchText.trim();
103
- if (!input)
104
- return;
105
- // Check if there are any existing results
106
- if (existingContacts.length === 0) {
107
- // No results found, check if it's a valid user ID
108
- if (isValidUserId(input)) {
109
- // Clear search and create new contact with the ID
110
- searchTextObs("");
111
- await createContact(input);
100
+ const groups = await (0, peers_sdk_1.Groups)(userContext.userDataContext).list({ disabled: { $ne: true } });
101
+ for (const group of groups) {
102
+ const context = userContext.getDataContext(group.groupId);
103
+ const members = await (0, peers_sdk_1.GroupMembers)(context).list({ deleted: { $ne: true } });
104
+ for (const member of members) {
105
+ if (member.userId === userContext.userId) {
106
+ continue;
112
107
  }
108
+ const existing = summaries.get(member.userId);
109
+ const user = personalById.get(member.userId) ?? (await (0, peers_sdk_1.Users)(context).get(member.userId));
110
+ if (!user) {
111
+ continue;
112
+ }
113
+ const summary = existing ?? {
114
+ user,
115
+ isContact: false,
116
+ sharedGroups: [],
117
+ };
118
+ if (!summary.sharedGroups.some((entry) => entry.groupId === group.groupId)) {
119
+ summary.sharedGroups.push({ groupId: group.groupId, name: group.name });
120
+ }
121
+ summaries.set(member.userId, summary);
113
122
  }
114
123
  }
115
- function renderItems(contacts) {
116
- const elements = [];
117
- let lastSource = null;
118
- contacts.forEach((contact, index) => {
119
- // Add horizontal separator when source changes
120
- if (lastSource !== contact.source) {
121
- elements.push((0, jsx_runtime_1.jsxs)("div", { className: "my-3", children: [lastSource !== null && (0, jsx_runtime_1.jsx)("hr", { className: "border-secondary opacity-50" }), contact.source === "userDataContext" && ((0, jsx_runtime_1.jsxs)("h5", { children: ["Personal Contacts ", lastSource !== null && "(not in this group)"] })), contact.source === "currentDataContext" && (0, jsx_runtime_1.jsx)("h5", { children: "Users in this group" }), contact.source === "otherDataContexts" && (0, jsx_runtime_1.jsx)("h5", { children: "Users from other groups" })] }, `separator-${index}`));
122
- lastSource = contact.source;
123
- }
124
- elements.push((0, jsx_runtime_1.jsx)(ContactCard, { contact: contact }, contact.userId));
125
- });
126
- return elements;
124
+ return [...summaries.values()].sort((left, right) => left.user.name.localeCompare(right.user.name));
125
+ }
126
+ /** Compact, fully clickable row in the People directory. */
127
+ function PersonRow(props) {
128
+ const { row } = props;
129
+ const groups = row.sharedGroups;
130
+ const relationship = row.isContact
131
+ ? groups.length > 0
132
+ ? `Contact · ${sharedGroupLabel(groups)}`
133
+ : "Contact"
134
+ : sharedGroupLabel(groups);
135
+ return ((0, jsx_runtime_1.jsxs)("a", { href: `#${identity_routes_1.IDENTITY_PEOPLE_PATH}/${row.user.userId}`, className: "list-group-item list-group-item-action d-flex align-items-center gap-3 py-3", children: [(0, jsx_runtime_1.jsx)("span", { className: "rounded-circle bg-body-secondary d-inline-flex align-items-center justify-content-center flex-shrink-0 fw-semibold", style: { width: 42, height: 42 }, "aria-hidden": "true", children: initials(row.user.name) }), (0, jsx_runtime_1.jsxs)("span", { className: "flex-grow-1 overflow-hidden", children: [(0, jsx_runtime_1.jsxs)("span", { className: "d-flex align-items-center gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsx)("span", { className: "fw-semibold text-truncate", children: row.user.name || "Unnamed person" }), props.duplicateName ? ((0, jsx_runtime_1.jsxs)("code", { className: "small text-muted", children: ["\u2026", row.user.userId.slice(-6)] })) : null] }), (0, jsx_runtime_1.jsx)("span", { className: "small text-muted text-truncate d-block", children: relationship })] }), row.isContact ? ((0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: row.trustLevel ?? peers_sdk_1.TrustLevel.Unknown })) : ((0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-light border", children: "Shared group" })), (0, jsx_runtime_1.jsx)("i", { className: "bi bi-chevron-right text-muted", "aria-hidden": "true" })] }));
136
+ }
137
+ function sharedGroupLabel(groups) {
138
+ if (groups.length === 0) {
139
+ return "Known through a shared group";
140
+ }
141
+ if (groups.length === 1) {
142
+ return `Shared group: ${groups[0].name}`;
127
143
  }
128
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-fluid p-3", 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", { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-person-fill-check me-2" }), "Contacts"] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2", children: [(0, jsx_runtime_1.jsxs)("a", { href: `#${invite_capture_1.ACCEPT_INVITE_PATH}`, className: "btn btn-outline-primary btn-sm", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-qr-code-scan me-1" }), "Accept invite"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-primary btn-sm", onClick: () => setInviting(true), disabled: inviting, children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-person-plus me-1" }), "Invite"] })] })] }), inviting ? (0, jsx_runtime_1.jsx)(InviteContactPanel, { onClose: () => setInviting(false) }) : null, (0, jsx_runtime_1.jsx)(pending_invites_list_1.PendingInvitesList, { type: "contact", hideWhenEmpty: true, title: "Pending contact invites" }), (0, jsx_runtime_1.jsx)("div", { className: "input-group mb-3", children: (0, jsx_runtime_1.jsx)(input_1.Input, { value: searchTextObs, className: "form-control", placeholder: "Search or create contacts", autoFocus: true, onKeyUp: handleSearchSubmit }) }), (0, jsx_runtime_1.jsx)("div", { className: "peers-list-container", children: (0, jsx_runtime_1.jsx)(lazy_list_1.LazyList, { resetTrigger: searchText, loadMore: loadMore, scrollThreshold: 0.6, renderItems: renderItems, loadingIndicator: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 }, children: (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {}) }), endOfList: (0, jsx_runtime_1.jsx)("div", { className: "d-flex justify-content-center", style: { height: 200 } }) }) })] }));
144
+ return `${groups.length} shared groups`;
145
+ }
146
+ function initials(name) {
147
+ const parts = name.trim().split(/\s+/).filter(Boolean);
148
+ return (parts
149
+ .slice(0, 2)
150
+ .map((part) => part[0]?.toLocaleUpperCase())
151
+ .join("") || "?");
129
152
  }
130
- const ContactCard = ({ contact }) => {
131
- const getSourceLabel = (source) => {
132
- switch (source) {
133
- case "userDataContext":
134
- return "Personal";
135
- case "currentDataContext":
136
- return "Current Context";
137
- case "otherDataContexts":
138
- return "Groups";
139
- default:
140
- return source;
141
- }
142
- };
143
- const getSourceIcon = (source) => {
144
- switch (source) {
145
- case "userDataContext":
146
- return "bi-person-fill";
147
- case "currentDataContext":
148
- return "bi-house-fill";
149
- case "otherDataContexts":
150
- return "bi-people-fill";
151
- default:
152
- return "bi-question-circle";
153
- }
154
- };
155
- return ((0, jsx_runtime_1.jsxs)("div", { className: "list-group-item d-flex align-items-center justify-content-between py-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-person-circle me-3", style: { fontSize: "32px", color: "#6c757d" } }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("strong", { children: contact.name }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center", children: [(0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: contact.userId }), (0, jsx_runtime_1.jsx)("span", { className: "mx-1 text-muted", children: "\u2022" }), (0, jsx_runtime_1.jsxs)("small", { className: "text-info d-flex align-items-center", children: [(0, jsx_runtime_1.jsx)("i", { className: `${getSourceIcon(contact.source)} me-1` }), getSourceLabel(contact.source)] })] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center", children: [(0, jsx_runtime_1.jsx)(trust_level_badge_1.TrustLevelBadge, { level: contact.trustLevel || peers_sdk_1.TrustLevel.Unknown }), (0, jsx_runtime_1.jsxs)("a", { href: `#contacts/${contact.userId}`, className: "btn btn-outline-primary btn-sm ms-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-eye me-1" }), "View"] })] })] }));
156
- };
157
- (0, ui_loader_1.registerInternalPeersUI)({
158
- peersUIId: "00mfc4ql0lgqmjxrpvcd4ufk0",
159
- component: ContactList,
160
- routes: [
161
- {
162
- isMatch: (_props, context) => context.path === "contacts",
163
- uiCategory: "screen",
164
- priority: 2,
165
- },
166
- ],
167
- });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const peers_sdk_1 = require("@peers-app/peers-sdk");
5
+ const react_1 = require("@testing-library/react");
6
+ const contact_list_1 = require("./contact-list");
7
+ jest.mock("../../globals", () => ({
8
+ mainContentPath: jest.fn(),
9
+ }));
10
+ jest.mock("../../hooks", () => ({
11
+ usePromise: jest.fn(),
12
+ }));
13
+ jest.mock("../../ui-router/ui-loader", () => ({
14
+ registerInternalPeersUI: jest.fn(),
15
+ }));
16
+ jest.mock("../identity/invite-contact-panel", () => ({
17
+ InviteContactPanel: () => null,
18
+ }));
19
+ jest.mock("../identity/profile-invite", () => ({
20
+ ProfileInviteSection: () => null,
21
+ }));
22
+ jest.mock("../invites/activity-view", () => ({
23
+ PendingWorkLink: () => null,
24
+ }));
25
+ const person = {
26
+ user: {
27
+ userId: "abcdefghijklmnopqrstuvwxy",
28
+ name: "Alex",
29
+ publicKey: "signing-key",
30
+ publicBoxKey: "box-key",
31
+ },
32
+ isContact: true,
33
+ trustLevel: peers_sdk_1.TrustLevel.Known,
34
+ sharedGroups: [{ groupId: "zyxwvutsrqponmlkjihgfedcb", name: "Neighbors" }],
35
+ };
36
+ describe("PersonRow", () => {
37
+ it("shows useful relationship context without exposing an ordinary user id", () => {
38
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(contact_list_1.PersonRow, { row: person, duplicateName: false }));
39
+ expect(react_1.screen.getByText("Alex")).toBeInTheDocument();
40
+ expect(react_1.screen.getByText("Contact · Shared group: Neighbors")).toBeInTheDocument();
41
+ expect(react_1.screen.queryByText(person.user.userId)).not.toBeInTheDocument();
42
+ expect(react_1.screen.getByRole("link")).toHaveAttribute("href", `#identity/people/${person.user.userId}`);
43
+ });
44
+ it("adds only a short discriminator when names collide", () => {
45
+ (0, react_1.render)((0, jsx_runtime_1.jsx)(contact_list_1.PersonRow, { row: person, duplicateName: true }));
46
+ expect(react_1.screen.getByText(`…${person.user.userId.slice(-6)}`)).toBeInTheDocument();
47
+ });
48
+ });
@@ -1,4 +1,2 @@
1
- import "./contact-list";
2
- import "./contact-details";
3
1
  export * from "./contact-details";
4
2
  export * from "./contact-list";