forked from Goutam/lynkeduppro-crm
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:
@@ -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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user