forked from Goutam/lynkeduppro-crm
feat(messenger): group settings UI — rename, member list, add/remove
- messenger-api: useGroupSettings(threadId) — members query + rename/add/ remove commands + isAdmin (from the member roles); live + mock - messenger: a settings (gear) button on group thread headers opens a GroupSettingsModal — editable name (admin), member list with role pills, admin-gated remove, and add-from-directory search - controls are admin-gated in the UI; IIOS/OPA re-enforces server-side Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
|
||||
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui";
|
||||
import { useMessengerData, useThread, type Membership, type UiAttachment, type UiConversation, type UiMessage, type UiPerson } from "@/lib/messenger-api";
|
||||
import { useMessengerData, useThread, useGroupSettings, type Membership, type UiAttachment, type UiConversation, type UiMember, type UiMessage, type UiPerson } from "@/lib/messenger-api";
|
||||
import { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket";
|
||||
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
|
||||
|
||||
@@ -107,7 +107,7 @@ function MessengerPanel() {
|
||||
|
||||
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
{current ? (
|
||||
<ThreadView key={current.threadId} conv={current} nameOf={m.nameOf} onError={(msg) => toast.push({ tone: "error", title: "Message failed", desc: msg })} />
|
||||
<ThreadView key={current.threadId} conv={current} nameOf={m.nameOf} directory={m.directory} onError={(msg) => toast.push({ tone: "error", title: "Message failed", desc: msg })} />
|
||||
) : (
|
||||
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
|
||||
<Icon name="send" size={38} />
|
||||
@@ -162,7 +162,7 @@ function ConversationRow({ c, active, onClick }: { c: UiConversation; active: bo
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (id: string) => string; onError: (m: string) => void }) {
|
||||
function ThreadView({ conv, nameOf, directory, onError }: { conv: UiConversation; nameOf: (id: string) => string; directory: UiPerson[]; onError: (m: string) => void }) {
|
||||
const t = useThread(conv.threadId);
|
||||
const socket = useMessengerSocket();
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -227,6 +227,8 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
|
||||
finally { setSending(false); }
|
||||
}
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const typingLabel = t.typingUserIds.length === 1
|
||||
? `${nameOf(t.typingUserIds[0])} is typing…`
|
||||
: t.typingUserIds.length > 1 ? "Several people are typing…" : "";
|
||||
@@ -235,13 +237,21 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
|
||||
<>
|
||||
<header style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
|
||||
<Avatar initials={initialsOf(conv.title)} size={34} gradient={conv.membership === "group" ? GROUP_GRAD : undefined} />
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{conv.title}</div>
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>
|
||||
{conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"}
|
||||
</div>
|
||||
</div>
|
||||
{conv.membership === "group" && (
|
||||
<button onClick={() => setSettingsOpen(true)} title="Group settings" style={{ ...actionBtnStyle, width: 34, height: 34 }}>
|
||||
<Icon name="settings" size={18} />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
{conv.membership === "group" && settingsOpen && (
|
||||
<GroupSettingsModal conv={conv} directory={directory} onClose={() => setSettingsOpen(false)} onError={onError} />
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 18, display: "flex", flexDirection: "column", gap: 10, background: "var(--bg)" }}>
|
||||
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading messages…</div>}
|
||||
@@ -451,3 +461,90 @@ function NewChatModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Group settings: rename, member list with roles, add/remove — admin-gated (IIOS/OPA re-enforces). */
|
||||
function GroupSettingsModal({ conv, directory, onClose, onError }: {
|
||||
conv: UiConversation; directory: UiPerson[]; onClose: () => void; onError: (m: string) => void;
|
||||
}) {
|
||||
const g = useGroupSettings(conv.threadId);
|
||||
const [name, setName] = useState(conv.subject ?? "");
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { setName(conv.subject ?? ""); }, [conv.subject]);
|
||||
|
||||
const memberIds = useMemo(() => new Set(g.members.map((m) => m.userId)), [g.members]);
|
||||
const nameChanged = name.trim() && name.trim() !== (conv.subject ?? "").trim();
|
||||
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||
|
||||
async function saveName() {
|
||||
if (!nameChanged || savingName) return;
|
||||
setSavingName(true);
|
||||
try { await g.rename(name.trim()); }
|
||||
catch (e) { onError((e as Error).message); }
|
||||
finally { setSavingName(false); }
|
||||
}
|
||||
async function add(userId: string) {
|
||||
setPendingId(userId);
|
||||
try { await g.addMember(userId); }
|
||||
catch (e) { onError((e as Error).message); }
|
||||
finally { setPendingId(null); }
|
||||
}
|
||||
async function remove(userId: string) {
|
||||
setPendingId(userId);
|
||||
try { await g.removeMember(userId); }
|
||||
catch (e) { onError((e as Error).message); }
|
||||
finally { setPendingId(null); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open onClose={onClose} title="Group settings" subtitle={conv.title} icon="settings"
|
||||
footer={<Btn variant="ghost" onClick={onClose}>Done</Btn>}
|
||||
>
|
||||
<Field label="Group name">
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} disabled={!g.isAdmin}
|
||||
placeholder="Group name" style={{ ...inputStyle, opacity: g.isAdmin ? 1 : 0.6 }} />
|
||||
{g.isAdmin && <Btn onClick={() => void saveName()} disabled={!nameChanged || savingName}>{savingName ? "…" : "Save"}</Btn>}
|
||||
</div>
|
||||
{!g.isAdmin && <div style={{ color: "var(--muted)", fontSize: 12, marginTop: 4 }}>Only a group admin can rename the group.</div>}
|
||||
</Field>
|
||||
|
||||
<Field label={`Members (${g.members.length})`}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 200, overflowY: "auto" }}>
|
||||
{g.loading && g.members.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>Loading…</div>}
|
||||
{g.members.map((mem: UiMember) => (
|
||||
<div key={mem.userId} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10 }}>
|
||||
<Avatar initials={initialsOf(mem.displayName)} size={28} gradient={GROUP_GRAD} />
|
||||
<span style={{ flex: 1 }}>{mem.displayName}</span>
|
||||
{mem.role === "ADMIN" && <Pill tone="purple">admin</Pill>}
|
||||
{g.isAdmin && mem.role !== "ADMIN" && (
|
||||
<button onClick={() => void remove(mem.userId)} disabled={pendingId === mem.userId} title="Remove"
|
||||
style={{ ...actionBtnStyle, width: 28, height: 28 }}><Icon name="trash" size={15} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{g.isAdmin && (
|
||||
<Field label="Add member">
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 180, overflowY: "auto" }}>
|
||||
{addable.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No one to add.</div>}
|
||||
{addable.map((p) => (
|
||||
<button key={p.id} onClick={() => void add(p.id)} disabled={pendingId === p.id}
|
||||
style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: "transparent", border: "none", color: "var(--text)", textAlign: "left" }}>
|
||||
<Avatar initials={initialsOf(p.name)} size={28} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
|
||||
<span style={{ flex: 1 }}>{p.name}</span>
|
||||
<Icon name="plus" size={16} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user