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:
2026-07-18 17:17:45 +05:30
parent 1490fa3460
commit 7982c243c0
4 changed files with 135 additions and 22 deletions
+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;
}
export interface UiReaction { emoji: string; count: number; mine: boolean }
export interface UiAttachment { contentRef: string; mimeType: string; sizeBytes: number }
export interface UiMessage {
id: string; actorId: string | null; senderId?: string | null; text: string; at: string; mine: boolean;
parentInteractionId?: string | null;
attachment?: UiAttachment;
reactions?: UiReaction[];
}
@@ -78,7 +80,7 @@ export interface MessengerData {
export interface ThreadData {
loading: boolean; error: string | null;
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;
typingUserIds: string[];
seenIds: Set<string>;
@@ -236,11 +238,12 @@ function useLiveThread(threadId: string): ThreadData {
return out;
}, [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) {
await socket.send(threadId, content, opts); // echoes back over the socket as a 'message' event
} 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);
q.refetch();
}
+8 -3
View File
@@ -24,7 +24,7 @@ export interface MessengerSocket {
ready: boolean;
myUserId?: string;
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;
/** Fires for EVERY inbound message regardless of thread — drives live sidebar previews. */
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,
mine: !!myUserId && m.senderId === myUserId,
...(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),
});
@@ -113,10 +114,14 @@ function LiveSocketProvider({ children }: { children: ReactNode }) {
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;
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]);