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>
This commit is contained in:
@@ -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)" }}>
|
||||||
|
{staged && <div><StagedChip file={staged} onRemove={() => setStaged(null)} /></div>}
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<input ref={fileRef} type="file" style={{ display: "none" }} onChange={onPickFile} />
|
||||||
|
<Btn variant="ghost" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading}>{uploading ? "…" : ""}</Btn>
|
||||||
<input
|
<input
|
||||||
value={draft} onChange={(e) => setDraft(e.target.value)}
|
value={draft} onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }}
|
||||||
placeholder="Reply…" style={inputStyle}
|
placeholder="Reply…" style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<Btn icon="send" onClick={() => void reply()} disabled={sending || !draft.trim()}>Reply</Btn>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function AttachmentView({ att }: { att: UiAttachment }) {
|
|||||||
return (
|
return (
|
||||||
<a href={url ?? "#"} target={url ? "_blank" : undefined} rel="noreferrer"
|
<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 }}>
|
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="file" size={18} />
|
<Icon name="paperclip" size={18} />
|
||||||
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>Attachment</span>
|
<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>
|
<span style={{ color: "var(--muted)", fontSize: 12, flexShrink: 0 }}>{fmtBytes(att.sizeBytes)}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+18
-14
@@ -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: () => {} };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user