6 Commits

Author SHA1 Message Date
maaz519 28907acf0e fix(dashboard): portal modals to .dash-root so they anchor to the viewport
A transformed/overflow panel ancestor was trapping the modal overlay's
position:fixed, so the modal rendered offset inside the messenger/inbox panel
and clipped (Group settings sat inside the thread pane; the compose modal's
top was cut at the panel edge). Portal the overlay up to .dash-root — above
those panels but still inside the scoped design-system CSS — so it centers on
the viewport and the 90vh cap works. Falls back to inline render if no
.dash-root is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:52:32 +05:30
maaz519 54add81187 fix(dashboard): keep tall modal footers on-screen
The modal body is a flex child with overflow-y:auto but had no min-height:0,
so it refused to shrink below content height and pushed the footer past the
90vh cap (visible on the tall Group settings modal — the Done button was
clipped). Add flex:1 1 auto + min-height:0 so the body scrolls and the
footer stays pinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:46:55 +05:30
maaz519 4ad0decad0 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>
2026-07-18 17:43:52 +05:30
maaz519 0b433790a5 feat(inbox): attachments in mail reader + composer
- mail-api: MailMessage carries attachment; reply + sendInternal +
  sendExternal accept uploaded attachments; mock updated
- mail.tsx: MailAttachmentView (inline image or file chip via signed URL),
  StagedChip; attach button in the reply footer and the New Message
  composer (multi-file, up to 10); text optional when a file is attached
- messenger: file-chip icon uses paperclip (was an unknown name)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:31:22 +05:30
maaz519 7982c243c0 feat: attachments in Messenger — upload/preview images + files
- media-api: useUploadAttachment (presign → direct PUT to IIOS storage),
  useDownloadUrl (short-lived signed URL), isImage, 25MB cap
- socket + REST send now carry attachment {contentRef, mimeType, sizeBytes}
- composer: 📎 attach button, staged chip, send with attachment (text optional)
- MessageBubble renders AttachmentView (inline image or file chip), aligned
  to the message side; empty bubble suppressed for attachment-only messages

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:17:45 +05:30
maaz519 1490fa3460 fix(messenger): reply focuses composer + quoted message jumps to original
- Clicking Reply now focuses the composer input (was requiring a manual click).
- A quoted message is clickable → scrolls to the original and flashes it briefly
  (was inert). Message elements register refs by id for the jump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:03:44 +05:30
8 changed files with 475 additions and 67 deletions
+1 -1
View File
@@ -437,7 +437,7 @@
.dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); } .dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); }
.dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; } .dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; }
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; } .dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
.dash-root .ds-modal-body { padding: 18px 20px; overflow-y: auto; } .dash-root .ds-modal-body { padding: 18px 20px; overflow-y: auto; flex: 1 1 auto; min-height: 0; }
.dash-root .ds-modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border); } .dash-root .ds-modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border); }
/* ---- Toasts ---- */ /* ---- Toasts ---- */
+111 -18
View File
@@ -7,9 +7,10 @@
// compose a new message. HTML bodies render in a sandboxed iframe. // compose a new message. HTML bodies render in a sandboxed iframe.
// ============================================================ // ============================================================
import { type CSSProperties, useEffect, useState } from "react"; import { type CSSProperties, useEffect, useRef, useState } from "react";
import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui"; import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui";
import { useMailThread, useMailCompose, type MailPerson } from "@/lib/mail-api"; import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api";
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
const timeOf = (iso?: string) => { const timeOf = (iso?: string) => {
if (!iso) return ""; if (!iso) return "";
@@ -22,18 +23,78 @@ const inputStyle: CSSProperties = {
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none", background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none",
}; };
function fmtBytes(n: number): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */
function MailAttachmentView({ att }: { att: MailAttachment }) {
const getUrl = useDownloadUrl();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let alive = true;
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
return () => { alive = false; };
}, [att.contentRef, att.mimeType, getUrl]);
const label = att.filename || "Attachment";
if (isImage(att.mimeType)) {
return url
? <a href={url} target="_blank" rel="noreferrer" style={{ display: "inline-block" }}><img src={url} alt={label} style={{ maxWidth: 320, maxHeight: 240, borderRadius: 8, border: "1px solid var(--border)" }} /></a>
: <div style={{ color: "var(--muted)", fontSize: 13 }}>Loading image</div>;
}
return (
<a href={url ?? "#"} target="_blank" rel="noreferrer"
style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "8px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--panel-2)", color: "var(--text)", textDecoration: "none", maxWidth: 320 }}>
<Icon name="paperclip" size={18} />
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
{att.sizeBytes > 0 && <span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(att.sizeBytes)}</span>}
</a>
);
}
/** A small staged-file chip shown in a composer before send, with a remove button. */
function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) {
return (
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "5px 10px", borderRadius: 999, background: "var(--panel-2)", border: "1px solid var(--border)", fontSize: 13 }}>
<Icon name="paperclip" size={14} />
<span style={{ maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.filename}</span>
<span style={{ color: "var(--muted)" }}>{fmtBytes(file.sizeBytes)}</span>
<button onClick={onRemove} title="Remove" style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", padding: 0, lineHeight: 1 }}></button>
</div>
);
}
/** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */ /** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */
export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) { export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) {
const t = useMailThread(threadId); const t = useMailThread(threadId);
const upload = useUploadAttachment();
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
try { setStaged(await upload(file)); }
catch (err) { onError((err as Error).message); }
finally { setUploading(false); }
}
async function reply() { async function reply() {
const text = draft.trim(); const text = draft.trim();
if (!text || sending) return; if ((!text && !staged) || sending) return;
setDraft(""); setSending(true); const att = staged ?? undefined;
try { await t.reply(text); } setDraft(""); setStaged(null); setSending(true);
catch (e) { setDraft(text); onError((e as Error).message); } try { await t.reply(text, att); }
catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); }
finally { setSending(false); } finally { setSending(false); }
} }
@@ -53,17 +114,25 @@ export function MailReader({ threadId, subject, onError }: { threadId: string; s
</div> </div>
{m.html {m.html
? <iframe sandbox="" srcDoc={m.html} title="mail body" style={{ width: "100%", height: 200, border: "none", background: "#fff" }} /> ? <iframe sandbox="" srcDoc={m.html} title="mail body" style={{ width: "100%", height: 200, border: "none", background: "#fff" }} />
: <div style={{ padding: 12, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14 }}>{m.text}</div>} : m.text
? <div style={{ padding: 12, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14 }}>{m.text}</div>
: null}
{m.attachment && <div style={{ padding: 12, paddingTop: m.html || m.text ? 0 : 12 }}><MailAttachmentView att={m.attachment} /></div>}
</div> </div>
))} ))}
</div> </div>
<footer style={{ display: "flex", gap: 8, padding: 12, borderTop: "1px solid var(--border)" }}> <footer style={{ display: "flex", flexDirection: "column", gap: 8, padding: 12, borderTop: "1px solid var(--border)" }}>
<input {staged && <div><StagedChip file={staged} onRemove={() => setStaged(null)} /></div>}
value={draft} onChange={(e) => setDraft(e.target.value)} <div style={{ display: "flex", gap: 8 }}>
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }} <input ref={fileRef} type="file" style={{ display: "none" }} onChange={onPickFile} />
placeholder="Reply…" style={inputStyle} <Btn variant="ghost" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading}>{uploading ? "…" : ""}</Btn>
/> <input
<Btn icon="send" onClick={() => void reply()} disabled={sending || !draft.trim()}>Reply</Btn> value={draft} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }}
placeholder="Reply…" style={inputStyle}
/>
<Btn icon="send" onClick={() => void reply()} disabled={sending || (!draft.trim() && !staged)}>Reply</Btn>
</div>
</footer> </footer>
</div> </div>
); );
@@ -72,24 +141,40 @@ export function MailReader({ threadId, subject, onError }: { threadId: string; s
/** Compose a new message — in-app (to a person) or external (to an email). */ /** Compose a new message — in-app (to a person) or external (to an email). */
export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean; onClose: () => void; onSent: () => void; onError: (m: string) => void }) { export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean; onClose: () => void; onSent: () => void; onError: (m: string) => void }) {
const compose = useMailCompose(onSent); const compose = useMailCompose(onSent);
const upload = useUploadAttachment();
const [mode, setMode] = useState<"internal" | "external">("internal"); const [mode, setMode] = useState<"internal" | "external">("internal");
const [recipient, setRecipient] = useState(""); const [recipient, setRecipient] = useState("");
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [staged, setStaged] = useState<UploadedAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (!open) { setMode("internal"); setRecipient(""); setSubject(""); setBody(""); setQ(""); setBusy(false); } }, [open]); useEffect(() => { if (!open) { setMode("internal"); setRecipient(""); setSubject(""); setBody(""); setQ(""); setBusy(false); setStaged([]); setUploading(false); } }, [open]);
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(files.map((f) => upload(f)));
setStaged((s) => [...s, ...uploaded].slice(0, 10));
} catch (err) { onError((err as Error).message); }
finally { setUploading(false); }
}
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase())); const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy; const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy && !uploading;
async function send() { async function send() {
if (!canSend) return; if (!canSend) return;
setBusy(true); setBusy(true);
try { try {
if (mode === "internal") await compose.sendInternal(recipient, subject.trim(), body.trim()); if (mode === "internal") await compose.sendInternal(recipient, subject.trim(), body.trim(), staged.length ? staged : undefined);
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim()); else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), staged.length ? { attachments: staged } : undefined);
} catch (e) { onError((e as Error).message); } } catch (e) { onError((e as Error).message); }
finally { setBusy(false); } finally { setBusy(false); }
} }
@@ -130,6 +215,14 @@ export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field> <Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field> <Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
<Field label="Attachments">
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
</div>
</Field>
</Modal> </Modal>
); );
} }
+224 -26
View File
@@ -12,8 +12,37 @@
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 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";
const fmtBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
/** Renders a message attachment — an inline image thumbnail, or a downloadable file chip. */
function AttachmentView({ att }: { att: UiAttachment }) {
const getUrl = useDownloadUrl();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let alive = true;
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
return () => { alive = false; };
}, [att.contentRef, att.mimeType, getUrl]);
if (isImage(att.mimeType)) {
return url ? (
// eslint-disable-next-line @next/next/no-img-element
<a href={url} target="_blank" rel="noreferrer"><img src={url} alt="attachment" style={{ maxWidth: 240, maxHeight: 240, borderRadius: 10, display: "block", marginTop: 6, border: "1px solid var(--border)" }} /></a>
) : <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 12 }}>Loading image</div>;
}
return (
<a href={url ?? "#"} target={url ? "_blank" : undefined} rel="noreferrer"
style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)", textDecoration: "none", color: "var(--text)", maxWidth: 240 }}>
<Icon name="paperclip" size={18} />
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>Attachment</span>
<span style={{ color: "var(--muted)", fontSize: 12, flexShrink: 0 }}>{fmtBytes(att.sizeBytes)}</span>
</a>
);
}
const initialsOf = (name: string) => const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
@@ -78,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} />
@@ -133,13 +162,16 @@ 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("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [replyTo, setReplyTo] = useState<UiMessage | null>(null); const [replyTo, setReplyTo] = useState<UiMessage | null>(null);
const [flashId, setFlashId] = useState<string | null>(null);
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const msgRefs = useRef<Map<string, HTMLElement>>(new Map());
const typingSentAt = useRef(0); const typingSentAt = useRef(0);
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]); useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]);
@@ -147,22 +179,56 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]); const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]);
const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]); const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]);
// Reply → focus the composer (bug: it didn't focus, forcing a manual click).
function startReply(msg: UiMessage) {
setReplyTo(msg);
requestAnimationFrame(() => inputRef.current?.focus());
}
// Click a quoted message → scroll to the original and flash it.
function jumpTo(id: string) {
const el = msgRefs.current.get(id);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
setFlashId(id);
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1200);
}
const uploadAttachment = useUploadAttachment();
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
function onDraftChange(v: string) { function onDraftChange(v: string) {
setDraft(v); setDraft(v);
const now = Date.now(); const now = Date.now();
if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; } if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; }
} }
async function onPickFile(file: File | undefined) {
if (!file) return;
setUploading(true);
try { setStaged(await uploadAttachment(file)); }
catch (e) { onError((e as Error).message); }
finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; }
}
async function submit() { async function submit() {
const text = draft.trim(); const text = draft.trim();
if (!text || sending) return; if ((!text && !staged) || sending) return; // allow an attachment with no text
const parent = replyTo?.id; const parent = replyTo?.id;
setDraft(""); setReplyTo(null); setSending(true); const att = staged;
try { await t.send(text, parent ? { parentInteractionId: parent } : undefined); } setDraft(""); setReplyTo(null); setStaged(null); setSending(true);
catch (e) { setDraft(text); onError((e as Error).message); } try {
await t.send(text, {
...(parent ? { parentInteractionId: parent } : {}),
...(att ? { attachment: { contentRef: att.contentRef, mimeType: att.mimeType, sizeBytes: att.sizeBytes } } : {}),
});
} catch (e) { setDraft(text); setStaged(att); onError((e as Error).message); }
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…" : "";
@@ -171,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>}
@@ -188,8 +262,11 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
parent={msg.parentInteractionId ? byId[msg.parentInteractionId] : undefined} parent={msg.parentInteractionId ? byId[msg.parentInteractionId] : undefined}
seen={msg.id === lastMineId && t.seenIds.has(msg.id)} seen={msg.id === lastMineId && t.seenIds.has(msg.id)}
showStatus={msg.id === lastMineId} showStatus={msg.id === lastMineId}
flash={flashId === msg.id}
registerRef={(el) => { if (el) msgRefs.current.set(msg.id, el); else msgRefs.current.delete(msg.id); }}
onReact={(emoji) => t.react(msg.id, emoji)} onReact={(emoji) => t.react(msg.id, emoji)}
onReply={() => setReplyTo(msg)} onReply={() => startReply(msg)}
onQuoteClick={jumpTo}
/> />
))} ))}
<div ref={endRef} /> <div ref={endRef} />
@@ -207,49 +284,77 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
</div> </div>
)} )}
<footer style={{ display: "flex", gap: 8, padding: 14, borderTop: "1px solid var(--border)" }}> {staged && (
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)" }}>
<Icon name={isImage(staged.mimeType) ? "image" : "file"} size={16} />
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>{staged.filename}</span>
<span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(staged.sizeBytes)}</span>
<button onClick={() => setStaged(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Remove attachment">×</button>
</div>
)}
<footer style={{ display: "flex", gap: 8, padding: 14, borderTop: "1px solid var(--border)", alignItems: "center" }}>
<input ref={fileRef} type="file" hidden onChange={(e) => void onPickFile(e.target.files?.[0])} />
<button onClick={() => fileRef.current?.click()} disabled={uploading} title="Attach a file" style={{ ...actionBtnStyle, width: 38, height: 38, flexShrink: 0, opacity: uploading ? 0.5 : 1 }}>
{uploading ? "…" : "📎"}
</button>
<input <input
ref={inputRef}
value={draft} onChange={(e) => onDraftChange(e.target.value)} value={draft} onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
placeholder="Type a message…" style={inputStyle} placeholder="Type a message…" style={inputStyle}
/> />
<Btn icon="send" onClick={() => void submit()} disabled={sending || !draft.trim()}>Send</Btn> <Btn icon="send" onClick={() => void submit()} disabled={sending || (!draft.trim() && !staged)}>Send</Btn>
</footer> </footer>
</> </>
); );
} }
function MessageBubble({ function MessageBubble({
msg, parent, seen, showStatus, onReact, onReply, msg, parent, seen, showStatus, flash, registerRef, onReact, onReply, onQuoteClick,
}: { }: {
msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean; msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean; flash?: boolean;
onReact: (emoji: string) => void; onReply: () => void; registerRef?: (el: HTMLElement | null) => void;
onReact: (emoji: string) => void; onReply: () => void; onQuoteClick?: (id: string) => void;
}) { }) {
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const [picker, setPicker] = useState(false); const [picker, setPicker] = useState(false);
return ( return (
<div <div
ref={registerRef}
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPicker(false); }} onMouseLeave={() => { setHover(false); setPicker(false); }}
style={{ alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column", alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative" }} style={{
alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column",
alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative",
borderRadius: 14, padding: 2, transition: "background 0.4s",
background: flash ? "rgba(253,169,19,0.22)" : "transparent",
}}
> >
{parent && ( {parent && (
<div style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}> <button
type="button"
onClick={() => parent.id && onQuoteClick?.(parent.id)}
title="Go to message"
style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", border: "none", borderLeftWidth: 3, fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", cursor: "pointer", textAlign: "left" }}
>
<span style={{ opacity: 0.8 }}> {parent.text}</span> <span style={{ opacity: 0.8 }}> {parent.text}</span>
</div> </button>
)} )}
<div style={{ display: "flex", alignItems: "center", gap: 6, flexDirection: msg.mine ? "row-reverse" : "row" }}> <div style={{ display: "flex", alignItems: "center", gap: 6, flexDirection: msg.mine ? "row-reverse" : "row" }}>
<div style={{ {(msg.text || !msg.attachment) && (
background: msg.mine ? "var(--grad-brand)" : "var(--panel)", color: msg.mine ? "#fff" : "var(--text)", <div style={{
padding: "8px 12px", borderRadius: 14, background: msg.mine ? "var(--grad-brand)" : "var(--panel)", color: msg.mine ? "#fff" : "var(--text)",
borderBottomRightRadius: msg.mine ? 4 : 14, borderBottomLeftRadius: msg.mine ? 14 : 4, padding: "8px 12px", borderRadius: 14,
border: msg.mine ? "none" : "1px solid var(--border)", borderBottomRightRadius: msg.mine ? 4 : 14, borderBottomLeftRadius: msg.mine ? 14 : 4,
whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14, border: msg.mine ? "none" : "1px solid var(--border)",
}}> whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14,
{msg.text} }}>
</div> {msg.text}
</div>
)}
{hover && ( {hover && (
<div style={{ display: "flex", gap: 2, position: "relative" }}> <div style={{ display: "flex", gap: 2, position: "relative" }}>
<button onClick={() => setPicker((p) => !p)} title="React" style={actionBtnStyle}>🙂</button> <button onClick={() => setPicker((p) => !p)} title="React" style={actionBtnStyle}>🙂</button>
@@ -265,6 +370,12 @@ function MessageBubble({
)} )}
</div> </div>
{msg.attachment && (
<div style={{ marginTop: 4, display: "flex", justifyContent: msg.mine ? "flex-end" : "flex-start" }}>
<AttachmentView att={msg.attachment} />
</div>
)}
{msg.reactions && msg.reactions.length > 0 && ( {msg.reactions && msg.reactions.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 3, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 4, marginTop: 3, flexWrap: "wrap" }}>
{msg.reactions.map((r) => ( {msg.reactions.map((r) => (
@@ -350,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>
);
}
+9 -2
View File
@@ -14,6 +14,7 @@ import {
createContext, useCallback, useContext, useEffect, useId, createContext, useCallback, useContext, useEffect, useId,
useRef, useState, type ReactNode, useRef, useState, type ReactNode,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { import {
MessageCircle, Ticket, Phone, Mail, BookOpen, Rocket, Shield, ShieldCheck, MessageCircle, Ticket, Phone, Mail, BookOpen, Rocket, Shield, ShieldCheck,
Lock, CreditCard, User, Bell, Eye, EyeOff, Camera, Upload, Plus, Star, Send, Lock, CreditCard, User, Bell, Eye, EyeOff, Camera, Upload, Plus, Star, Send,
@@ -221,6 +222,11 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg"; children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg";
}) { }) {
const titleId = useId(); const titleId = useId();
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport,
// not to a transformed/overflow panel ancestor (which would clip or offset the modal).
const [host, setHost] = useState<Element | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => { setHost(document.querySelector(".dash-root")); setMounted(true); }, []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
@@ -228,8 +234,8 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
return () => document.removeEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
if (!open) return null; if (!open || !mounted) return null;
return ( const overlay = (
<div className="ds-modal-overlay" onMouseDown={onClose}> <div className="ds-modal-overlay" onMouseDown={onClose}>
<div className={`ds-modal size-${size}`} role="dialog" aria-modal="true" aria-labelledby={titleId} onMouseDown={(e) => e.stopPropagation()}> <div className={`ds-modal size-${size}`} role="dialog" aria-modal="true" aria-labelledby={titleId} onMouseDown={(e) => e.stopPropagation()}>
<div className="ds-modal-head"> <div className="ds-modal-head">
@@ -247,6 +253,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
</div> </div>
</div> </div>
); );
return host ? createPortal(overlay, host) : overlay;
} }
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
+18 -14
View File
@@ -19,11 +19,15 @@ import { isShellConfigured } from "./appshell";
export interface MailThread { export interface MailThread {
threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string; threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
} }
export interface MailAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null }
export interface MailMessage { export interface MailMessage {
interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null; interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null; attachment: MailAttachment | null;
} }
export interface MailPerson { id: string; name: string; kind: "staff" | "customer" } export interface MailPerson { id: string; name: string; kind: "staff" | "customer" }
/** Shape produced by media-api's useUploadAttachment, passed into a reply/compose. */
export interface OutgoingAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string }
const SHELL = isShellConfigured(); const SHELL = isShellConfigured();
/* ============================ Thread list ============================ */ /* ============================ Thread list ============================ */
@@ -43,7 +47,7 @@ export function useMailThreads(): MailListData {
/* ============================ One thread ============================ */ /* ============================ One thread ============================ */
export interface MailThreadData { export interface MailThreadData {
loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string) => Promise<void>; refetch: () => void; loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string, attachment?: OutgoingAttachment) => Promise<void>; refetch: () => void;
} }
export function useMailThread(threadId: string | null): MailThreadData { export function useMailThread(threadId: string | null): MailThreadData {
@@ -54,9 +58,9 @@ export function useMailThread(threadId: string | null): MailThreadData {
function useLiveThread(threadId: string | null): MailThreadData { function useLiveThread(threadId: string | null): MailThreadData {
const { sdk } = useAppShell(); const { sdk } = useAppShell();
const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" }); const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" });
const reply = useCallback(async (content: string) => { const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
if (!threadId) return; if (!threadId) return;
await sdk.command("crm.mail.reply", { threadId, content }); await sdk.command("crm.mail.reply", { threadId, content, ...(attachment ? { attachment } : {}) });
q.refetch(); q.refetch();
}, [sdk, threadId, q]); }, [sdk, threadId, q]);
return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch }; return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch };
@@ -66,8 +70,8 @@ function useLiveThread(threadId: string | null): MailThreadData {
export interface ComposeData { export interface ComposeData {
directory: MailPerson[]; directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string) => Promise<void>; sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, mirrorToUserId?: string) => Promise<void>; sendExternal: (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => Promise<void>;
} }
export function useMailCompose(onSent: () => void): ComposeData { export function useMailCompose(onSent: () => void): ComposeData {
@@ -75,12 +79,12 @@ export function useMailCompose(onSent: () => void): ComposeData {
const { sdk } = useAppShell(); const { sdk } = useAppShell();
const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 }); const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 });
const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]); const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]);
const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string) => { const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => {
await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>` }); await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(attachments && attachments.length ? { attachments } : {}) });
onSent(); onSent();
}, [sdk, onSent]); }, [sdk, onSent]);
const sendExternal = useCallback(async (target: string, subject: string, text: string, mirrorToUserId?: string) => { const sendExternal = useCallback(async (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => {
await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(mirrorToUserId ? { mirrorToUserId } : {}) }); await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(opts?.mirrorToUserId ? { mirrorToUserId: opts.mirrorToUserId } : {}), ...(opts?.attachments && opts.attachments.length ? { attachments: opts.attachments } : {}) });
onSent(); onSent();
}, [sdk, onSent]); }, [sdk, onSent]);
return { directory, sendInternal, sendExternal }; return { directory, sendInternal, sendExternal };
@@ -106,12 +110,12 @@ const MOCK_THREADS: MailThread[] = [
function useMockThread(threadId: string | null): MailThreadData { function useMockThread(threadId: string | null): MailThreadData {
const [extra, setExtra] = useState<MailMessage[]>([]); const [extra, setExtra] = useState<MailMessage[]>([]);
const base: MailMessage[] = threadId === "mt_1" const base: MailMessage[] = threadId === "mt_1"
? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club." }] ? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club.", attachment: null }]
: threadId === "mt_2" : threadId === "mt_2"
? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7." }] ? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7.", attachment: null }]
: []; : [];
const reply = useCallback(async (content: string) => { const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content }]); setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content, attachment: attachment ? { contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, filename: attachment.filename } : null }]);
}, []); }, []);
return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} }; return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} };
} }
+37
View File
@@ -0,0 +1,37 @@
"use client";
// Media (attachment) helpers over the be-crm data door (crm.media.*). The browser transfers bytes
// DIRECTLY to IIOS storage via the signed URLs — be-crm only mints them. Used by Messenger + Mail.
import { useCallback } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
export interface UploadedAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string }
export const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024; // matches IIOS's cap
export function isImage(mime?: string | null): boolean {
return !!mime && mime.startsWith("image/");
}
/** Upload a File → { contentRef, mimeType, sizeBytes, filename }. Throws on oversize / failure. */
export function useUploadAttachment() {
const { sdk } = useAppShell();
return useCallback(async (file: File): Promise<UploadedAttachment> => {
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
const mime = file.type || "application/octet-stream";
const { objectKey, uploadUrl } = (await sdk.command("crm.media.presignUpload", { mime, sizeBytes: file.size })) as { objectKey: string; uploadUrl: string };
const res = await fetch(uploadUrl, { method: "PUT", body: file });
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name };
}, [sdk]);
}
/** Mint a short-lived signed URL to display/download an attachment by its contentRef. */
export function useDownloadUrl() {
const { sdk } = useAppShell();
return useCallback(async (contentRef: string, mime?: string): Promise<string> => {
const { url } = (await sdk.command("crm.media.presignDownload", { contentRef, ...(mime ? { mime } : {}) })) as { url: string };
return url;
}, [sdk]);
}
+67 -3
View File
@@ -28,9 +28,11 @@ export interface UiConversation {
participants: string[]; unread: number; lastMessage?: string; lastAt?: string; participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
} }
export interface UiReaction { emoji: string; count: number; mine: boolean } export interface UiReaction { emoji: string; count: number; mine: boolean }
export interface UiAttachment { contentRef: string; mimeType: string; sizeBytes: number }
export interface UiMessage { export interface UiMessage {
id: string; actorId: string | null; senderId?: string | null; text: string; at: string; mine: boolean; id: string; actorId: string | null; senderId?: string | null; text: string; at: string; mine: boolean;
parentInteractionId?: string | null; parentInteractionId?: string | null;
attachment?: UiAttachment;
reactions?: UiReaction[]; reactions?: UiReaction[];
} }
@@ -78,19 +80,33 @@ export interface MessengerData {
export interface ThreadData { export interface ThreadData {
loading: boolean; error: string | null; loading: boolean; error: string | null;
messages: UiMessage[]; messages: UiMessage[];
send: (content: string, opts?: { parentInteractionId?: string }) => Promise<void>; send: (content: string, opts?: { parentInteractionId?: string; attachment?: UiAttachment }) => Promise<void>;
react: (interactionId: string, emoji: string) => void; react: (interactionId: string, emoji: string) => void;
typingUserIds: string[]; typingUserIds: string[];
seenIds: Set<string>; seenIds: Set<string>;
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) */
@@ -236,11 +252,12 @@ function useLiveThread(threadId: string): ThreadData {
return out; return out;
}, [seenIds, messages]); }, [seenIds, messages]);
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string }) => { const send = useCallback(async (content: string, opts?: { parentInteractionId?: string; attachment?: UiAttachment }) => {
if (socket && socketReady) { if (socket && socketReady) {
await socket.send(threadId, content, opts); // echoes back over the socket as a 'message' event await socket.send(threadId, content, opts); // echoes back over the socket as a 'message' event
} else { } else {
const m = (await sdk.command("crm.messenger.send", { threadId, content })) as MessageDTO; // REST fallback carries the attachment ref too; a socket reconnect will replace with the live copy.
const m = (await sdk.command("crm.messenger.send", { threadId, content, ...(opts?.attachment ? { attachment: opts.attachment } : {}) })) as MessageDTO;
if (m.actorId) setMyActorId(m.actorId); if (m.actorId) setMyActorId(m.actorId);
q.refetch(); q.refetch();
} }
@@ -256,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,
@@ -369,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 };
}
+8 -3
View File
@@ -24,7 +24,7 @@ export interface MessengerSocket {
ready: boolean; ready: boolean;
myUserId?: string; myUserId?: string;
openThread: (threadId: string) => Promise<UiMessage[]>; openThread: (threadId: string) => Promise<UiMessage[]>;
send: (threadId: string, content: string, opts?: { parentInteractionId?: string }) => Promise<void>; send: (threadId: string, content: string, opts?: { parentInteractionId?: string; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => Promise<void>;
subscribe: (threadId: string, cb: (m: UiMessage) => void) => () => void; subscribe: (threadId: string, cb: (m: UiMessage) => void) => () => void;
/** Fires for EVERY inbound message regardless of thread — drives live sidebar previews. */ /** Fires for EVERY inbound message regardless of thread — drives live sidebar previews. */
onAnyMessage: (cb: (threadId: string, m: UiMessage) => void) => () => void; onAnyMessage: (cb: (threadId: string, m: UiMessage) => void) => () => void;
@@ -47,6 +47,7 @@ const toUi = (m: Message, myUserId?: string): UiMessage => ({
id: m.id, actorId: m.senderActorId ?? null, senderId: m.senderId ?? null, text: m.content ?? "", at: m.createdAt, id: m.id, actorId: m.senderActorId ?? null, senderId: m.senderId ?? null, text: m.content ?? "", at: m.createdAt,
mine: !!myUserId && m.senderId === myUserId, mine: !!myUserId && m.senderId === myUserId,
...(m.parentInteractionId ? { parentInteractionId: m.parentInteractionId } : {}), ...(m.parentInteractionId ? { parentInteractionId: m.parentInteractionId } : {}),
...(m.attachment ? { attachment: { contentRef: m.attachment.contentRef, mimeType: m.attachment.mimeType, sizeBytes: m.attachment.sizeBytes } } : {}),
reactions: toReactions(m.annotations, myUserId), reactions: toReactions(m.annotations, myUserId),
}); });
@@ -113,10 +114,14 @@ function LiveSocketProvider({ children }: { children: ReactNode }) {
return res.history.map((m) => toUi(m, myRef.current)); return res.history.map((m) => toUi(m, myRef.current));
}, []); }, []);
const send = useCallback(async (threadId: string, content: string, opts?: { parentInteractionId?: string }) => { const send = useCallback(async (threadId: string, content: string, opts?: { parentInteractionId?: string; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => {
const s = socketRef.current; const s = socketRef.current;
if (!s) throw new Error("Not connected"); if (!s) throw new Error("Not connected");
await s.sendMessage(threadId, content, opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined); const sendOpts = {
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
await s.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
}, []); }, []);
const subscribe = useCallback((threadId: string, cb: (m: UiMessage) => void) => msgReg.add(threadId, cb), [msgReg]); const subscribe = useCallback((threadId: string, cb: (m: UiMessage) => void) => msgReg.add(threadId, cb), [msgReg]);