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>
This commit is contained in:
@@ -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) => (
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user