@peers-app/peers-ui 0.23.14 → 0.25.0

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 (93) hide show
  1. package/biome.json +2 -2
  2. package/dist/components/qr-scanner.d.ts +18 -0
  3. package/dist/components/qr-scanner.js +165 -0
  4. package/dist/components/router.d.ts +1 -1
  5. package/dist/components/router.js +1 -1
  6. package/dist/root-layout/root-layout-host.js +18 -19
  7. package/dist/root-layout/root-layout-host.test.js +14 -9
  8. package/dist/screens/account/account-screen.js +52 -1
  9. package/dist/screens/contacts/contact-list.js +28 -1
  10. package/dist/screens/contacts/index.d.ts +0 -2
  11. package/dist/screens/contacts/index.js +0 -2
  12. package/dist/screens/device-pairing/device-pairing.d.ts +2 -4
  13. package/dist/screens/device-pairing/device-pairing.js +127 -128
  14. package/dist/screens/device-pairing/device-pairing.test.js +51 -15
  15. package/dist/screens/device-pairing/index.d.ts +0 -1
  16. package/dist/screens/device-pairing/index.js +0 -1
  17. package/dist/screens/groups/group-details.js +2 -6
  18. package/dist/screens/groups/group-invite-panel.d.ts +14 -0
  19. package/dist/screens/groups/group-invite-panel.js +125 -0
  20. package/dist/screens/groups/group-members.js +9 -103
  21. package/dist/screens/invites/accept-invite.d.ts +11 -0
  22. package/dist/screens/invites/accept-invite.js +114 -0
  23. package/dist/screens/invites/index.d.ts +7 -0
  24. package/dist/screens/{join-group → invites}/index.js +8 -3
  25. package/dist/screens/invites/invite-capture.d.ts +34 -0
  26. package/dist/screens/invites/invite-capture.js +94 -0
  27. package/dist/screens/invites/invite-share.d.ts +15 -0
  28. package/dist/screens/invites/invite-share.js +40 -0
  29. package/dist/screens/invites/invites-ipc.d.ts +10 -0
  30. package/dist/screens/invites/invites-ipc.js +61 -0
  31. package/dist/screens/invites/invites-screen.d.ts +9 -0
  32. package/dist/screens/invites/invites-screen.js +36 -0
  33. package/dist/screens/invites/pending-invites-list.d.ts +29 -0
  34. package/dist/screens/invites/pending-invites-list.js +156 -0
  35. package/dist/screens/settings/settings-page.js +1 -1
  36. package/dist/screens/setup-user.js +4 -18
  37. package/dist/system-apps/index.d.ts +1 -1
  38. package/dist/system-apps/index.js +5 -5
  39. package/dist/system-apps/{join-group.app.d.ts → invites.app.d.ts} +1 -1
  40. package/dist/system-apps/invites.app.js +9 -0
  41. package/package.json +4 -3
  42. package/src/components/qr-scanner.tsx +180 -0
  43. package/src/components/router.tsx +1 -1
  44. package/src/root-layout/root-layout-host.test.tsx +18 -13
  45. package/src/root-layout/root-layout-host.tsx +23 -21
  46. package/src/screens/account/account-screen.tsx +89 -2
  47. package/src/screens/contacts/contact-list.tsx +64 -4
  48. package/src/screens/contacts/index.ts +0 -2
  49. package/src/screens/device-pairing/device-pairing.test.tsx +61 -16
  50. package/src/screens/device-pairing/device-pairing.tsx +283 -208
  51. package/src/screens/device-pairing/index.ts +0 -1
  52. package/src/screens/groups/group-details.tsx +1 -41
  53. package/src/screens/groups/group-invite-panel.tsx +316 -0
  54. package/src/screens/groups/group-members.tsx +15 -177
  55. package/src/screens/invites/accept-invite.tsx +271 -0
  56. package/src/screens/invites/index.ts +9 -0
  57. package/src/screens/invites/invite-capture.ts +107 -0
  58. package/src/screens/invites/invite-share.tsx +104 -0
  59. package/src/screens/invites/invites-ipc.ts +74 -0
  60. package/src/screens/invites/invites-screen.tsx +78 -0
  61. package/src/screens/invites/pending-invites-list.tsx +224 -0
  62. package/src/screens/settings/settings-page.tsx +2 -2
  63. package/src/screens/setup-user.tsx +29 -47
  64. package/src/system-apps/index.ts +3 -3
  65. package/src/system-apps/invites.app.ts +8 -0
  66. package/dist/screens/contacts/user-connect-invite.d.ts +0 -118
  67. package/dist/screens/contacts/user-connect-invite.js +0 -264
  68. package/dist/screens/contacts/user-connect-invite.test.d.ts +0 -1
  69. package/dist/screens/contacts/user-connect-invite.test.js +0 -215
  70. package/dist/screens/contacts/user-connect.d.ts +0 -8
  71. package/dist/screens/contacts/user-connect.js +0 -487
  72. package/dist/screens/contacts/user-connect.test.d.ts +0 -1
  73. package/dist/screens/contacts/user-connect.test.js +0 -291
  74. package/dist/screens/device-pairing/device-pairing-invite.d.ts +0 -10
  75. package/dist/screens/device-pairing/device-pairing-invite.js +0 -50
  76. package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +0 -1
  77. package/dist/screens/device-pairing/device-pairing-invite.test.js +0 -30
  78. package/dist/screens/groups/group-invite-listener.d.ts +0 -14
  79. package/dist/screens/groups/group-invite-listener.js +0 -93
  80. package/dist/screens/join-group/index.d.ts +0 -2
  81. package/dist/screens/join-group/join-group.d.ts +0 -12
  82. package/dist/screens/join-group/join-group.js +0 -102
  83. package/dist/system-apps/join-group.app.js +0 -9
  84. package/src/screens/contacts/user-connect-invite.test.ts +0 -256
  85. package/src/screens/contacts/user-connect-invite.ts +0 -332
  86. package/src/screens/contacts/user-connect.test.tsx +0 -382
  87. package/src/screens/contacts/user-connect.tsx +0 -883
  88. package/src/screens/device-pairing/device-pairing-invite.test.ts +0 -50
  89. package/src/screens/device-pairing/device-pairing-invite.ts +0 -55
  90. package/src/screens/groups/group-invite-listener.tsx +0 -282
  91. package/src/screens/join-group/index.ts +0 -4
  92. package/src/screens/join-group/join-group.tsx +0 -296
  93. package/src/system-apps/join-group.app.ts +0 -8
@@ -1,5 +1,4 @@
1
1
  import {
2
- checkQRCodeCompatibility,
3
2
  copyToClipboard,
4
3
  Groups,
5
4
  generateGroupShare,
@@ -166,7 +165,6 @@ const ShareButton = ({ groupId }: { groupId: string }) => {
166
165
  const [shareResult, setShareResult] = useState<{
167
166
  success: boolean;
168
167
  message: string;
169
- qrInfo?: ReturnType<typeof checkQRCodeCompatibility>;
170
168
  } | null>(null);
171
169
 
172
170
  const handleShare = async () => {
@@ -177,9 +175,6 @@ const ShareButton = ({ groupId }: { groupId: string }) => {
177
175
  // Generate the group share data
178
176
  const groupShare = await generateGroupShare(groupId);
179
177
 
180
- // Check QR code compatibility
181
- const qrInfo = checkQRCodeCompatibility(groupShare, 20);
182
-
183
178
  // Convert to JSON
184
179
  const groupShareJson = JSON.stringify(groupShare, null, 2);
185
180
 
@@ -189,14 +184,12 @@ const ShareButton = ({ groupId }: { groupId: string }) => {
189
184
  if (copied) {
190
185
  setShareResult({
191
186
  success: true,
192
- message: `Group share data copied to clipboard! (${qrInfo.size} chars, ${qrInfo.memberCount} admin members)`,
193
- qrInfo,
187
+ message: `Group share data copied to clipboard! (${groupShareJson.length} chars, ${groupShare.groupMembers.length} admin members)`,
194
188
  });
195
189
  } else {
196
190
  setShareResult({
197
191
  success: false,
198
192
  message: "Failed to copy to clipboard. Please copy manually from the modal.",
199
- qrInfo,
200
193
  });
201
194
  }
202
195
  } catch (error: unknown) {
@@ -256,39 +249,6 @@ const ShareButton = ({ groupId }: { groupId: string }) => {
256
249
  <div className="card-body">
257
250
  <p className="mb-3">{shareResult.message}</p>
258
251
 
259
- {shareResult.qrInfo && (
260
- <div className="mb-3">
261
- <h6>QR Code Compatibility</h6>
262
- <div className="row g-2">
263
- <div className="col-6">
264
- <small className="text-muted">Size:</small>
265
- <br />
266
- <strong>{shareResult.qrInfo.size} chars</strong>
267
- </div>
268
- <div className="col-6">
269
- <small className="text-muted">QR Version 20:</small>
270
- <br />
271
- <span
272
- className={`badge bg-${shareResult.qrInfo.fits ? "success" : "warning"}`}
273
- >
274
- {shareResult.qrInfo.fits ? "✓ Fits" : "⚠ Too Large"} (
275
- {shareResult.qrInfo.usagePercent}%)
276
- </span>
277
- </div>
278
- </div>
279
-
280
- {!shareResult.qrInfo.fits && (
281
- <div className="alert alert-warning mt-2 mb-0">
282
- <small>
283
- <i className="bi-exclamation-triangle me-1"></i>
284
- Data may be too large for standard QR codes. Consider reducing admin members
285
- or use text sharing.
286
- </small>
287
- </div>
288
- )}
289
- </div>
290
- )}
291
-
292
252
  <div className="text-end">
293
253
  <button className="btn btn-secondary me-2" onClick={() => setShareResult(null)}>
294
254
  Close
@@ -0,0 +1,316 @@
1
+ import {
2
+ type GroupInviteMode,
3
+ GroupMemberRole,
4
+ type ICreatedInvite,
5
+ type IUserWithSource,
6
+ usersCursor,
7
+ } from "@peers-app/peers-sdk";
8
+ import { useState } from "react";
9
+ import { Typeahead, type TypeaheadItem } from "../../components/typeahead";
10
+ import { InviteShare } from "../invites/invite-share";
11
+ import { inviteTools } from "../invites/invites-ipc";
12
+ import { PendingInvitesList } from "../invites/pending-invites-list";
13
+
14
+ /** Props for {@link GroupInvitePanel}. */
15
+ export interface GroupInvitePanelProps {
16
+ groupId: string;
17
+ /** Users who are already members; hidden from the contact picker. */
18
+ excludeUserIds: string[];
19
+ /** The local user's role; caps the roles offered. */
20
+ myRole: GroupMemberRole;
21
+ }
22
+
23
+ const ROLE_OPTIONS: Array<{ value: GroupMemberRole; label: string }> = [
24
+ { value: GroupMemberRole.Reader, label: "Reader" },
25
+ { value: GroupMemberRole.Writer, label: "Writer" },
26
+ { value: GroupMemberRole.Admin, label: "Admin" },
27
+ { value: GroupMemberRole.Owner, label: "Owner" },
28
+ ];
29
+
30
+ const EXPIRY_OPTIONS: Array<{ ms: number; label: string }> = [
31
+ { ms: 60 * 60_000, label: "1 hour" },
32
+ { ms: 24 * 60 * 60_000, label: "1 day" },
33
+ { ms: 7 * 24 * 60 * 60_000, label: "7 days" },
34
+ { ms: 30 * 24 * 60 * 60_000, label: "30 days" },
35
+ ];
36
+
37
+ function RoleSelect(props: {
38
+ value: GroupMemberRole;
39
+ max: GroupMemberRole;
40
+ disabled?: boolean;
41
+ onChange: (role: GroupMemberRole) => void;
42
+ }) {
43
+ return (
44
+ <select
45
+ className="form-select"
46
+ value={props.value}
47
+ disabled={props.disabled}
48
+ onChange={(event) => props.onChange(Number(event.target.value) as GroupMemberRole)}
49
+ aria-label="Role"
50
+ >
51
+ {ROLE_OPTIONS.filter((option) => option.value <= props.max).map((option) => (
52
+ <option key={option.value} value={option.value}>
53
+ {option.label}
54
+ </option>
55
+ ))}
56
+ </select>
57
+ );
58
+ }
59
+
60
+ function ContactPicker(props: {
61
+ excludeUserIds: string[];
62
+ selected: IUserWithSource[];
63
+ disabled: boolean;
64
+ onChange: (items: IUserWithSource[]) => void;
65
+ }) {
66
+ const search = async (query: string): Promise<(IUserWithSource & TypeaheadItem)[]> => {
67
+ const cursor = await usersCursor({ name: { $matchWords: query } }, { includeTrustLevel: true });
68
+ const results: IUserWithSource[] = [];
69
+ for await (const contact of cursor) {
70
+ if (!props.excludeUserIds.includes(contact.userId)) {
71
+ results.push(contact);
72
+ }
73
+ if (results.length >= 10) break;
74
+ }
75
+ return results.map((contact) => ({ ...contact, id: contact.userId }));
76
+ };
77
+ return (
78
+ <Typeahead
79
+ placeholder="Search contacts…"
80
+ searchFn={search}
81
+ onSelectionChange={(items) => props.onChange(items as IUserWithSource[])}
82
+ renderItem={(contact) => (
83
+ <div className="d-flex align-items-center">
84
+ <i className="bi-person-circle me-2" style={{ fontSize: 20 }} />
85
+ <div className="flex-grow-1">
86
+ <div className="fw-semibold">{(contact as IUserWithSource).name}</div>
87
+ <small className="text-muted">{(contact as IUserWithSource).userId}</small>
88
+ </div>
89
+ </div>
90
+ )}
91
+ renderBadge={(contact) => <span>{(contact as IUserWithSource).name}</span>}
92
+ selectedItems={props.selected.map((contact) => ({ ...contact, id: contact.userId }))}
93
+ multiSelect
94
+ disabled={props.disabled}
95
+ />
96
+ );
97
+ }
98
+
99
+ /** Invite known contacts directly (they get a ready-to-accept invite; no link needed). */
100
+ function InviteContactsCard(props: GroupInvitePanelProps) {
101
+ const [selected, setSelected] = useState<IUserWithSource[]>([]);
102
+ const [role, setRole] = useState<GroupMemberRole>(GroupMemberRole.Writer);
103
+ const [busy, setBusy] = useState(false);
104
+ const [message, setMessage] = useState<{ ok: boolean; text: string } | undefined>(undefined);
105
+
106
+ const send = async () => {
107
+ setBusy(true);
108
+ setMessage(undefined);
109
+ try {
110
+ const tools = await inviteTools();
111
+ let queued = 0;
112
+ for (const contact of selected) {
113
+ const { result } = await tools.inviteContactToGroup({
114
+ groupId: props.groupId,
115
+ userId: contact.userId,
116
+ role,
117
+ });
118
+ if (result.deliveryState === "queued") queued++;
119
+ }
120
+ setMessage({
121
+ ok: true,
122
+ text:
123
+ queued > 0
124
+ ? `Invited ${selected.length}. ${queued} will be delivered when they are reachable.`
125
+ : `Invited ${selected.length}. They will appear once they accept.`,
126
+ });
127
+ setSelected([]);
128
+ } catch (err) {
129
+ setMessage({ ok: false, text: (err as Error).message || "Could not send the invite." });
130
+ } finally {
131
+ setBusy(false);
132
+ }
133
+ };
134
+
135
+ return (
136
+ <div className="row g-2 align-items-start">
137
+ <div className="col-md-6">
138
+ <ContactPicker
139
+ excludeUserIds={props.excludeUserIds}
140
+ selected={selected}
141
+ disabled={busy}
142
+ onChange={setSelected}
143
+ />
144
+ </div>
145
+ <div className="col-md-3">
146
+ <RoleSelect value={role} max={props.myRole} disabled={busy} onChange={setRole} />
147
+ </div>
148
+ <div className="col-md-3">
149
+ <button
150
+ type="button"
151
+ className="btn btn-primary w-100"
152
+ disabled={busy || selected.length === 0}
153
+ onClick={() => void send()}
154
+ >
155
+ {busy
156
+ ? "Sending…"
157
+ : selected.length > 1
158
+ ? `Invite ${selected.length} contacts`
159
+ : "Invite contact"}
160
+ </button>
161
+ </div>
162
+ {message ? (
163
+ <div className="col-12">
164
+ <div className={`alert py-2 mb-0 ${message.ok ? "alert-success" : "alert-danger"}`}>
165
+ {message.text}
166
+ </div>
167
+ </div>
168
+ ) : null}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ /** Issue a shareable link for people who are not contacts yet. */
174
+ function ShareLinkCard(props: GroupInvitePanelProps) {
175
+ const [role, setRole] = useState<GroupMemberRole>(GroupMemberRole.Reader);
176
+ const [mode, setMode] = useState<GroupInviteMode>("approve");
177
+ const [expiresInMs, setExpiresInMs] = useState(EXPIRY_OPTIONS[1].ms);
178
+ const [busy, setBusy] = useState(false);
179
+ const [error, setError] = useState("");
180
+ const [invite, setInvite] = useState<ICreatedInvite | undefined>(undefined);
181
+
182
+ const create = async () => {
183
+ setBusy(true);
184
+ setError("");
185
+ try {
186
+ const tools = await inviteTools();
187
+ const { invite: created } = await tools.createGroupInvite({
188
+ groupId: props.groupId,
189
+ role,
190
+ mode,
191
+ expiresInMs,
192
+ });
193
+ setInvite(created);
194
+ } catch (err) {
195
+ setError((err as Error).message || "Could not create the link.");
196
+ } finally {
197
+ setBusy(false);
198
+ }
199
+ };
200
+
201
+ if (invite) {
202
+ return (
203
+ <InviteShare
204
+ invite={invite}
205
+ description={
206
+ mode === "open"
207
+ ? "Anyone with this link joins immediately. Revoke it from the pending list when you are done."
208
+ : "People who use this link appear below as join requests for you to approve."
209
+ }
210
+ shareText={`Join ${invite.token.group?.name ?? "my group"} on Peers:`}
211
+ onDone={() => setInvite(undefined)}
212
+ />
213
+ );
214
+ }
215
+
216
+ return (
217
+ <div className="row g-2 align-items-end">
218
+ <div className="col-md-3">
219
+ <label htmlFor="link-role" className="form-label small text-muted mb-1">
220
+ Role
221
+ </label>
222
+ <RoleSelect value={role} max={props.myRole} disabled={busy} onChange={setRole} />
223
+ </div>
224
+ <div className="col-md-3">
225
+ <label htmlFor="link-mode" className="form-label small text-muted mb-1">
226
+ Approval
227
+ </label>
228
+ <select
229
+ id="link-mode"
230
+ className="form-select"
231
+ value={mode}
232
+ disabled={busy}
233
+ onChange={(event) => setMode(event.target.value as GroupInviteMode)}
234
+ >
235
+ <option value="approve">I approve each join</option>
236
+ <option value="open">Anyone with the link joins</option>
237
+ </select>
238
+ </div>
239
+ <div className="col-md-3">
240
+ <label htmlFor="link-expiry" className="form-label small text-muted mb-1">
241
+ Expires in
242
+ </label>
243
+ <select
244
+ id="link-expiry"
245
+ className="form-select"
246
+ value={expiresInMs}
247
+ disabled={busy}
248
+ onChange={(event) => setExpiresInMs(Number(event.target.value))}
249
+ >
250
+ {EXPIRY_OPTIONS.map((option) => (
251
+ <option key={option.ms} value={option.ms}>
252
+ {option.label}
253
+ </option>
254
+ ))}
255
+ </select>
256
+ </div>
257
+ <div className="col-md-3">
258
+ <button
259
+ type="button"
260
+ className="btn btn-outline-primary w-100"
261
+ disabled={busy}
262
+ onClick={() => void create()}
263
+ >
264
+ {busy ? "Creating…" : "Create link"}
265
+ </button>
266
+ </div>
267
+ {error ? (
268
+ <div className="col-12">
269
+ <div className="alert alert-danger py-2 mb-0">{error}</div>
270
+ </div>
271
+ ) : null}
272
+ </div>
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Admin controls for growing a group: invite existing contacts first, share a link second,
278
+ * and act on pending join requests. Membership changes land when the other side accepts.
279
+ */
280
+ export function GroupInvitePanel(props: GroupInvitePanelProps) {
281
+ const [tab, setTab] = useState<"contacts" | "link">("contacts");
282
+ return (
283
+ <>
284
+ <div className="card mb-3">
285
+ <div className="card-header d-flex align-items-center gap-3">
286
+ <h6 className="mb-0">Invite people</h6>
287
+ <div className="btn-group btn-group-sm ms-auto" role="group" aria-label="Invite method">
288
+ <button
289
+ type="button"
290
+ className={`btn ${tab === "contacts" ? "btn-primary" : "btn-outline-primary"}`}
291
+ onClick={() => setTab("contacts")}
292
+ >
293
+ Contacts
294
+ </button>
295
+ <button
296
+ type="button"
297
+ className={`btn ${tab === "link" ? "btn-primary" : "btn-outline-primary"}`}
298
+ onClick={() => setTab("link")}
299
+ >
300
+ Share a link
301
+ </button>
302
+ </div>
303
+ </div>
304
+ <div className="card-body">
305
+ {tab === "contacts" ? <InviteContactsCard {...props} /> : <ShareLinkCard {...props} />}
306
+ </div>
307
+ </div>
308
+ <PendingInvitesList
309
+ type="group"
310
+ groupId={props.groupId}
311
+ hideWhenEmpty
312
+ title="Pending invites and join requests"
313
+ />
314
+ </>
315
+ );
316
+ }
@@ -7,7 +7,6 @@ import {
7
7
  getUserContext,
8
8
  getUserRole,
9
9
  type IUser,
10
- type IUserWithSource,
11
10
  setUserTrustLevel,
12
11
  TrustLevel,
13
12
  type UserContext,
@@ -17,9 +16,8 @@ import {
17
16
  import React from "react";
18
17
  import { LoadingIndicator } from "../../components/loading-indicator";
19
18
  import { TrustLevelBadge } from "../../components/trust-level-badge";
20
- import { Typeahead, type TypeaheadItem } from "../../components/typeahead";
21
19
  import { usePromise } from "../../hooks";
22
- import { GroupInviteListener } from "./group-invite-listener";
20
+ import { GroupInvitePanel } from "./group-invite-panel";
23
21
 
24
22
  interface GroupMembersUIProps {
25
23
  groupId: string;
@@ -148,120 +146,13 @@ export const GroupMembersUI = (props: GroupMembersUIProps) => {
148
146
  [groupId, refreshKey],
149
147
  );
150
148
 
151
- const [newMemberRole, setNewMemberRole] = React.useState<GroupMemberRole>(GroupMemberRole.Reader);
152
- const [addingMember, setAddingMember] = React.useState(false);
153
- const [selectedContacts, setSelectedContacts] = React.useState<IUserWithSource[]>([]);
154
-
155
- // Search function for typeahead
156
- const searchContacts = async (query: string): Promise<(IUserWithSource & TypeaheadItem)[]> => {
157
- try {
158
- const cursor = await usersCursor(
159
- { name: { $matchWords: query } },
160
- { includeTrustLevel: true },
161
- );
162
- const results: IUserWithSource[] = [];
163
-
164
- // Get first 10 results
165
- for await (const contact of cursor) {
166
- results.push(contact);
167
- if (results.length >= 10) break;
168
- }
169
-
170
- // Filter out users who are already members
171
- const currentMembers = membersData?.map((m) => m.member.userId) || [];
172
- const filteredResults = results.filter((contact) => !currentMembers.includes(contact.userId));
173
-
174
- return filteredResults.map((contact) => ({
175
- ...contact,
176
- id: contact.userId,
177
- }));
178
- } catch (error) {
179
- console.error("Error searching contacts:", error);
180
- return [];
181
- }
182
- };
183
-
184
- // Render function for typeahead results
185
- const renderContactItem = (contact: IUserWithSource & TypeaheadItem, _isSelected: boolean) => (
186
- <div className="d-flex align-items-center">
187
- <i className="bi-person-circle me-2" style={{ fontSize: "20px" }} />
188
- <div className="flex-grow-1">
189
- <div className="fw-semibold">{contact.name}</div>
190
- <small className="text-muted">{contact.userId}</small>
191
- </div>
192
- <div>
193
- <small className="text-info">
194
- <i className={`bi-${getSourceIcon(contact.source)} me-1`} />
195
- {getSourceLabel(contact.source)}
196
- </small>
197
- </div>
198
- </div>
199
- );
200
-
201
- // Helper functions for contact sources
202
- const getSourceLabel = (source: string) => {
203
- switch (source) {
204
- case "userDataContext":
205
- return "Personal";
206
- case "currentDataContext":
207
- return "Current";
208
- case "otherDataContexts":
209
- return "Groups";
210
- default:
211
- return source;
212
- }
213
- };
214
-
215
- const getSourceIcon = (source: string) => {
216
- switch (source) {
217
- case "userDataContext":
218
- return "person-fill";
219
- case "currentDataContext":
220
- return "house-fill";
221
- case "otherDataContexts":
222
- return "people-fill";
223
- default:
224
- return "question-circle";
225
- }
226
- };
227
-
228
- const addMember = async () => {
229
- if (selectedContacts.length === 0) return;
230
-
231
- setAddingMember(true);
232
- try {
233
- // Add all selected contacts with the chosen role
234
- for (const contact of selectedContacts) {
235
- let member = await groupMembersTable.findOne({ userId: contact.userId, groupId });
236
- if (member) {
237
- member.role = newMemberRole;
238
- member.deleted = false;
239
- } else {
240
- member = await groupMembersTable.initRecord({
241
- groupId: groupId,
242
- userId: contact.userId,
243
- role: newMemberRole,
244
- });
245
- }
246
- await groupMembersTable.signAndSave(member);
247
- }
248
-
249
- // Reset form
250
- setSelectedContacts([]);
251
- setNewMemberRole(GroupMemberRole.Reader);
252
-
253
- // Refresh the members list
254
- setRefreshKey((prev) => prev + 1);
255
- } catch (err) {
256
- console.error("Failed to add member:", err);
257
- alert("Failed to add member. They may already be in the group.");
258
- } finally {
259
- setAddingMember(false);
260
- }
261
- };
262
-
263
- // Render function for contact badges
264
- const renderContactBadge = (contact: IUserWithSource) => <span>{contact.name}</span>;
149
+ // Refresh the member list when an accepted invite adds a membership row.
150
+ React.useEffect(() => {
151
+ const sub = groupMembersTable.dataChanged.subscribe(() => setRefreshKey((prev) => prev + 1));
152
+ return () => {
153
+ sub.unsubscribe();
154
+ };
155
+ }, [groupMembersTable]);
265
156
 
266
157
  const updateRole = async (memberId: string, newRole: GroupMemberRole) => {
267
158
  try {
@@ -322,64 +213,13 @@ export const GroupMembersUI = (props: GroupMembersUIProps) => {
322
213
  </div>
323
214
  </div>
324
215
 
325
- {/* Group Invite Listener - Only for admins/owners */}
326
- {canEdit && <GroupInviteListener groupId={groupId} userContext={userContext} />}
327
-
328
- {/* Add New Member - Only for admins/owners */}
216
+ {/* Invite people - Only for admins/owners */}
329
217
  {canEdit && (
330
- <div className="card mb-3">
331
- <div className="card-body">
332
- <h6 className="card-title">Add Existing Contact as Member</h6>
333
- <div className="row g-2">
334
- <div className="col-md-6">
335
- <Typeahead
336
- placeholder="Search contacts to add..."
337
- searchFn={searchContacts}
338
- onSelectionChange={(items) => setSelectedContacts(items as IUserWithSource[])}
339
- renderItem={renderContactItem}
340
- renderBadge={renderContactBadge}
341
- selectedItems={selectedContacts.map((contact) => ({
342
- ...contact,
343
- id: contact.userId,
344
- }))}
345
- multiSelect={true}
346
- disabled={addingMember}
347
- />
348
- </div>
349
- <div className="col-md-3">
350
- <select
351
- className="form-select"
352
- value={newMemberRole}
353
- onChange={(e) =>
354
- setNewMemberRole(Number(e.target.value) || (0 as GroupMemberRole))
355
- }
356
- disabled={addingMember}
357
- >
358
- <option value={GroupMemberRole.None}>None</option>
359
- <option value={GroupMemberRole.Reader}>Reader</option>
360
- <option value={GroupMemberRole.Writer}>Writer</option>
361
- <option value={GroupMemberRole.Admin}>Admin</option>
362
- <option value={GroupMemberRole.Owner}>Owner</option>
363
- </select>
364
- </div>
365
- <div className="col-md-3">
366
- <button
367
- className="btn btn-primary w-100"
368
- onClick={addMember}
369
- disabled={addingMember || selectedContacts.length === 0}
370
- >
371
- {addingMember
372
- ? "Adding..."
373
- : selectedContacts.length === 1
374
- ? "Add Member"
375
- : selectedContacts.length > 1
376
- ? `Add ${selectedContacts.length} Members`
377
- : "Add"}
378
- </button>
379
- </div>
380
- </div>
381
- </div>
382
- </div>
218
+ <GroupInvitePanel
219
+ groupId={groupId}
220
+ excludeUserIds={membersData.map((m) => m.member.userId)}
221
+ myRole={userRole}
222
+ />
383
223
  )}
384
224
 
385
225
  {!canEdit && userRole && (
@@ -391,9 +231,7 @@ export const GroupMembersUI = (props: GroupMembersUIProps) => {
391
231
 
392
232
  {/* Members List */}
393
233
  {membersData.length === 0 ? (
394
- <div className="alert alert-info">
395
- No members in this group yet. Add the first member above.
396
- </div>
234
+ <div className="alert alert-info">No members in this group yet. Invite someone above.</div>
397
235
  ) : (
398
236
  <div className="list-group">
399
237
  {membersData.map(({ member, user }) => (