diff --git a/src/components/dashboard/messenger.tsx b/src/components/dashboard/messenger.tsx index 9d8b965..a777e4b 100644 --- a/src/components/dashboard/messenger.tsx +++ b/src/components/dashboard/messenger.tsx @@ -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() {
{current ? ( - toast.push({ tone: "error", title: "Message failed", desc: msg })} /> + toast.push({ tone: "error", title: "Message failed", desc: msg })} /> ) : (
@@ -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: ( <>
-
+
{conv.title}
{conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"}
+ {conv.membership === "group" && ( + + )}
+ {conv.membership === "group" && settingsOpen && ( + setSettingsOpen(false)} onError={onError} /> + )}
{t.loading && t.messages.length === 0 &&
Loading messages…
} @@ -451,3 +461,90 @@ function NewChatModal({ ); } + +/** 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(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 ( + Done} + > + +
+ setName(e.target.value)} disabled={!g.isAdmin} + placeholder="Group name" style={{ ...inputStyle, opacity: g.isAdmin ? 1 : 0.6 }} /> + {g.isAdmin && void saveName()} disabled={!nameChanged || savingName}>{savingName ? "…" : "Save"}} +
+ {!g.isAdmin &&
Only a group admin can rename the group.
} +
+ + +
+ {g.loading && g.members.length === 0 &&
Loading…
} + {g.members.map((mem: UiMember) => ( +
+ + {mem.displayName} + {mem.role === "ADMIN" && admin} + {g.isAdmin && mem.role !== "ADMIN" && ( + + )} +
+ ))} +
+
+ + {g.isAdmin && ( + + setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} /> +
+ {addable.length === 0 &&
No one to add.
} + {addable.map((p) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/lib/messenger-api.ts b/src/lib/messenger-api.ts index 4c05cc2..a1f843a 100644 --- a/src/lib/messenger-api.ts +++ b/src/lib/messenger-api.ts @@ -87,12 +87,26 @@ export interface ThreadData { refetch: () => void; } +export interface UiMember { userId: string; displayName: string; role: string } +export interface GroupSettingsData { + loading: boolean; error: string | null; + members: UiMember[]; + isAdmin: boolean; + rename: (subject: string) => Promise; + addMember: (userId: string) => Promise; + removeMember: (userId: string) => Promise; + refetch: () => void; +} + export function useMessengerData(): MessengerData { return SHELL ? useLiveMessenger() : useMockMessenger(); } export function useThread(threadId: string): ThreadData { return SHELL ? useLiveThread(threadId) : useMockThread(threadId); } +export function useGroupSettings(threadId: string): GroupSettingsData { + return SHELL ? useLiveGroupSettings(threadId) : useMockGroupSettings(threadId); +} /* ======================================================================== */ /* Live implementation (be-crm data door + IIOS socket) */ @@ -259,6 +273,29 @@ function useLiveThread(threadId: string): ThreadData { }; } +function useLiveGroupSettings(threadId: string): GroupSettingsData { + const { sdk } = useAppShell(); + const { user } = useAuth(); + const q = useQuery("crm.messenger.members", { threadId }); + const members = useMemo(() => q.data ?? [], [q.data]); + const isAdmin = useMemo(() => members.some((m) => m.userId === user?.id && m.role === "ADMIN"), [members, user?.id]); + + const rename = useCallback(async (subject: string) => { + await sdk.command("crm.messenger.group.rename", { threadId, subject }); + q.refetch(); + }, [sdk, threadId, q]); + const addMember = useCallback(async (userId: string) => { + await sdk.command("crm.messenger.participant.add", { threadId, userId }); + q.refetch(); + }, [sdk, threadId, q]); + const removeMember = useCallback(async (userId: string) => { + await sdk.command("crm.messenger.participant.remove", { threadId, userId }); + q.refetch(); + }, [sdk, threadId, q]); + + return { loading: q.loading, error: q.error?.message ?? null, members, isAdmin, rename, addMember, removeMember, refetch: q.refetch }; +} + function shape( c: ConversationDTO, nameOf: (id: string) => string, @@ -372,3 +409,27 @@ function useMockThread(threadId: string): ThreadData { typingUserIds: [], seenIds: new Set(), refetch: notifyMock, }; } + +function useMockGroupSettings(threadId: string): GroupSettingsData { + useMockSubscription(); + const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []); + const t = MOCK_STORE.get(threadId); + const members: UiMember[] = (t?.participants ?? []).map((id) => ({ + userId: id, + displayName: id === "me" ? "You" : (nameById[id] ?? `User ${shortId(id)}`), + role: id === "me" ? "ADMIN" : "MEMBER", + })); + const rename = useCallback(async (subject: string) => { + const th = MOCK_STORE.get(threadId); + if (th) { th.subject = subject; notifyMock(); } + }, [threadId]); + const addMember = useCallback(async (userId: string) => { + const th = MOCK_STORE.get(threadId); + if (th && !th.participants.includes(userId)) { th.participants = [...th.participants, userId]; notifyMock(); } + }, [threadId]); + const removeMember = useCallback(async (userId: string) => { + const th = MOCK_STORE.get(threadId); + if (th) { th.participants = th.participants.filter((p) => p !== userId); notifyMock(); } + }, [threadId]); + return { loading: false, error: null, members, isAdmin: true, rename, addMember, removeMember, refetch: notifyMock }; +}