Feat/crm mail ui #30
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui";
|
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 { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket";
|
||||||
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
|
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 }}>
|
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
{current ? (
|
{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" }}>
|
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
|
||||||
<Icon name="send" size={38} />
|
<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 t = useThread(conv.threadId);
|
||||||
const socket = useMessengerSocket();
|
const socket = useMessengerSocket();
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@@ -227,6 +227,8 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
|
|||||||
finally { setSending(false); }
|
finally { setSending(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
const typingLabel = t.typingUserIds.length === 1
|
const typingLabel = t.typingUserIds.length === 1
|
||||||
? `${nameOf(t.typingUserIds[0])} is typing…`
|
? `${nameOf(t.typingUserIds[0])} is typing…`
|
||||||
: t.typingUserIds.length > 1 ? "Several people are 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)" }}>
|
<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} />
|
<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={{ fontWeight: 600 }}>{conv.title}</div>
|
||||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>
|
<div style={{ color: "var(--muted)", fontSize: 12 }}>
|
||||||
{conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"}
|
{conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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)" }}>
|
<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>}
|
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading messages…</div>}
|
||||||
@@ -451,3 +461,90 @@ function NewChatModal({
|
|||||||
</Modal>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,12 +87,26 @@ export interface ThreadData {
|
|||||||
refetch: () => void;
|
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<void>;
|
||||||
|
addMember: (userId: string) => Promise<void>;
|
||||||
|
removeMember: (userId: string) => Promise<void>;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function useMessengerData(): MessengerData {
|
export function useMessengerData(): MessengerData {
|
||||||
return SHELL ? useLiveMessenger() : useMockMessenger();
|
return SHELL ? useLiveMessenger() : useMockMessenger();
|
||||||
}
|
}
|
||||||
export function useThread(threadId: string): ThreadData {
|
export function useThread(threadId: string): ThreadData {
|
||||||
return SHELL ? useLiveThread(threadId) : useMockThread(threadId);
|
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) */
|
/* 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<UiMember[]>("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(
|
function shape(
|
||||||
c: ConversationDTO,
|
c: ConversationDTO,
|
||||||
nameOf: (id: string) => string,
|
nameOf: (id: string) => string,
|
||||||
@@ -372,3 +409,27 @@ function useMockThread(threadId: string): ThreadData {
|
|||||||
typingUserIds: [], seenIds: new Set(), refetch: notifyMock,
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user