Feat/crm mail ui #30

Merged
maaz519 merged 8 commits from feat/crm-mail-ui into goutamnextflow 2026-07-18 12:31:19 +00:00
4 changed files with 135 additions and 22 deletions
Showing only changes of commit 7982c243c0 - Show all commits
+75 -7
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, type Membership, type UiAttachment, type UiConversation, 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="file" 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() || "?";
@@ -164,19 +193,37 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1200); 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); }
} }
@@ -227,14 +274,27 @@ 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} 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>
</> </>
); );
@@ -274,6 +334,7 @@ function MessageBubble({
)} )}
<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" }}>
{(msg.text || !msg.attachment) && (
<div style={{ <div style={{
background: msg.mine ? "var(--grad-brand)" : "var(--panel)", color: msg.mine ? "#fff" : "var(--text)", background: msg.mine ? "var(--grad-brand)" : "var(--panel)", color: msg.mine ? "#fff" : "var(--text)",
padding: "8px 12px", borderRadius: 14, padding: "8px 12px", borderRadius: 14,
@@ -283,6 +344,7 @@ function MessageBubble({
}}> }}>
{msg.text} {msg.text}
</div> </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>
@@ -298,6 +360,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) => (
+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]);
}
+6 -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,7 +80,7 @@ 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>;
@@ -236,11 +238,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();
} }
+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]);