@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.
- package/biome.json +2 -2
- package/dist/components/qr-scanner.d.ts +18 -0
- package/dist/components/qr-scanner.js +165 -0
- package/dist/components/router.d.ts +1 -1
- package/dist/components/router.js +1 -1
- package/dist/root-layout/root-layout-host.js +18 -19
- package/dist/root-layout/root-layout-host.test.js +14 -9
- package/dist/screens/account/account-screen.js +52 -1
- package/dist/screens/contacts/contact-list.js +28 -1
- package/dist/screens/contacts/index.d.ts +0 -2
- package/dist/screens/contacts/index.js +0 -2
- package/dist/screens/device-pairing/device-pairing.d.ts +2 -4
- package/dist/screens/device-pairing/device-pairing.js +127 -128
- package/dist/screens/device-pairing/device-pairing.test.js +51 -15
- package/dist/screens/device-pairing/index.d.ts +0 -1
- package/dist/screens/device-pairing/index.js +0 -1
- package/dist/screens/groups/group-details.js +2 -6
- package/dist/screens/groups/group-invite-panel.d.ts +14 -0
- package/dist/screens/groups/group-invite-panel.js +125 -0
- package/dist/screens/groups/group-members.js +9 -103
- package/dist/screens/invites/accept-invite.d.ts +11 -0
- package/dist/screens/invites/accept-invite.js +114 -0
- package/dist/screens/invites/index.d.ts +7 -0
- package/dist/screens/{join-group → invites}/index.js +8 -3
- package/dist/screens/invites/invite-capture.d.ts +34 -0
- package/dist/screens/invites/invite-capture.js +94 -0
- package/dist/screens/invites/invite-share.d.ts +15 -0
- package/dist/screens/invites/invite-share.js +40 -0
- package/dist/screens/invites/invites-ipc.d.ts +10 -0
- package/dist/screens/invites/invites-ipc.js +61 -0
- package/dist/screens/invites/invites-screen.d.ts +9 -0
- package/dist/screens/invites/invites-screen.js +36 -0
- package/dist/screens/invites/pending-invites-list.d.ts +29 -0
- package/dist/screens/invites/pending-invites-list.js +156 -0
- package/dist/screens/settings/settings-page.js +1 -1
- package/dist/screens/setup-user.js +4 -18
- package/dist/system-apps/index.d.ts +1 -1
- package/dist/system-apps/index.js +5 -5
- package/dist/system-apps/{join-group.app.d.ts → invites.app.d.ts} +1 -1
- package/dist/system-apps/invites.app.js +9 -0
- package/package.json +4 -3
- package/src/components/qr-scanner.tsx +180 -0
- package/src/components/router.tsx +1 -1
- package/src/root-layout/root-layout-host.test.tsx +18 -13
- package/src/root-layout/root-layout-host.tsx +23 -21
- package/src/screens/account/account-screen.tsx +89 -2
- package/src/screens/contacts/contact-list.tsx +64 -4
- package/src/screens/contacts/index.ts +0 -2
- package/src/screens/device-pairing/device-pairing.test.tsx +61 -16
- package/src/screens/device-pairing/device-pairing.tsx +283 -208
- package/src/screens/device-pairing/index.ts +0 -1
- package/src/screens/groups/group-details.tsx +1 -41
- package/src/screens/groups/group-invite-panel.tsx +316 -0
- package/src/screens/groups/group-members.tsx +15 -177
- package/src/screens/invites/accept-invite.tsx +271 -0
- package/src/screens/invites/index.ts +9 -0
- package/src/screens/invites/invite-capture.ts +107 -0
- package/src/screens/invites/invite-share.tsx +104 -0
- package/src/screens/invites/invites-ipc.ts +74 -0
- package/src/screens/invites/invites-screen.tsx +78 -0
- package/src/screens/invites/pending-invites-list.tsx +224 -0
- package/src/screens/settings/settings-page.tsx +2 -2
- package/src/screens/setup-user.tsx +29 -47
- package/src/system-apps/index.ts +3 -3
- package/src/system-apps/invites.app.ts +8 -0
- package/dist/screens/contacts/user-connect-invite.d.ts +0 -118
- package/dist/screens/contacts/user-connect-invite.js +0 -264
- package/dist/screens/contacts/user-connect-invite.test.d.ts +0 -1
- package/dist/screens/contacts/user-connect-invite.test.js +0 -215
- package/dist/screens/contacts/user-connect.d.ts +0 -8
- package/dist/screens/contacts/user-connect.js +0 -487
- package/dist/screens/contacts/user-connect.test.d.ts +0 -1
- package/dist/screens/contacts/user-connect.test.js +0 -291
- package/dist/screens/device-pairing/device-pairing-invite.d.ts +0 -10
- package/dist/screens/device-pairing/device-pairing-invite.js +0 -50
- package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +0 -1
- package/dist/screens/device-pairing/device-pairing-invite.test.js +0 -30
- package/dist/screens/groups/group-invite-listener.d.ts +0 -14
- package/dist/screens/groups/group-invite-listener.js +0 -93
- package/dist/screens/join-group/index.d.ts +0 -2
- package/dist/screens/join-group/join-group.d.ts +0 -12
- package/dist/screens/join-group/join-group.js +0 -102
- package/dist/system-apps/join-group.app.js +0 -9
- package/src/screens/contacts/user-connect-invite.test.ts +0 -256
- package/src/screens/contacts/user-connect-invite.ts +0 -332
- package/src/screens/contacts/user-connect.test.tsx +0 -382
- package/src/screens/contacts/user-connect.tsx +0 -883
- package/src/screens/device-pairing/device-pairing-invite.test.ts +0 -50
- package/src/screens/device-pairing/device-pairing-invite.ts +0 -55
- package/src/screens/groups/group-invite-listener.tsx +0 -282
- package/src/screens/join-group/index.ts +0 -4
- package/src/screens/join-group/join-group.tsx +0 -296
- 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! (${
|
|
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 {
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
{/*
|
|
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
|
-
<
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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 }) => (
|