diff --git a/package-lock.json b/package-lock.json index 1563be7..84e4fdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@abe-kap/appshell-sdk": "^0.2.6", "@insignia/iios-kernel-client": "^0.1.4", - "@insignia/iios-messaging-ui": "^0.1.3", + "@insignia/iios-messaging-ui": "^0.1.4", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", @@ -1028,9 +1028,9 @@ } }, "node_modules/@insignia/iios-messaging-ui": { - "version": "0.1.3", - "resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.3/iios-messaging-ui-0.1.3.tgz", - "integrity": "sha512-zCHaL0rF7l4a1/xKQAo7TUJyRKILdRJcblAaPzX8kY3pJpqRGqmAppRVOXwmbBQisCWe2rhrSw9T/EJm8IzBxQ==", + "version": "0.1.4", + "resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.4/iios-messaging-ui-0.1.4.tgz", + "integrity": "sha512-xgwVPY//NFyCrGxuLpLlrXn89q2SMgQoKTiW46CWdLx6rkaaxSesVEijGEoYt25rVPVF9+RcDPbwIBQ2GDtHQw==", "peerDependencies": { "@insignia/iios-kernel-client": "*", "react": ">=18", diff --git a/package.json b/package.json index 396e465..443de9d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dependencies": { "@abe-kap/appshell-sdk": "^0.2.6", "@insignia/iios-kernel-client": "^0.1.4", - "@insignia/iios-messaging-ui": "^0.1.3", + "@insignia/iios-messaging-ui": "^0.1.4", "clsx": "^2.1.1", "lucide-react": "^1.21.0", "next": "16.2.9", diff --git a/src/lib/crm-messaging-adapter.ts b/src/lib/crm-messaging-adapter.ts index dbe8c21..6ea0b7e 100644 --- a/src/lib/crm-messaging-adapter.ts +++ b/src/lib/crm-messaging-adapter.ts @@ -6,6 +6,7 @@ // When no socket is available (token failed / demo), it degrades to a 4s history poll. import type { + Attachment, ChannelSummary, ChannelVisibility, Conversation, @@ -32,7 +33,7 @@ interface ConversationDTO { threadId: string; subject: string | null; membership: Membership | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string; } -interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null } +interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null; attachment?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null } const POLL_MS = 4000; const REACTION = "reaction"; @@ -58,7 +59,7 @@ export class CrmMessagingAdapter implements MessagingAdapter { if (socket) { socket.on("message", (m) => { this.ingestReactions(m); - this.emit(m.threadId, { kind: "message", message: this.fromKernel(m) }); + void this.toKernelMessage(m).then((message) => this.emit(m.threadId, { kind: "message", message })); }); socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId })); // Receipts carry no threadId → fan to all open threads; the UI filters by messageId. @@ -99,28 +100,43 @@ export class CrmMessagingAdapter implements MessagingAdapter { if (this.socket) { const res = await this.socket.openThread(threadId); // joins so live events flow this.joined.add(threadId); - return res.history.map((m) => { - this.ingestReactions(m); - return this.fromKernel(m); - }); + return Promise.all( + res.history.map((m) => { + this.ingestReactions(m); + return this.toKernelMessage(m); + }), + ); } const msgs = await this.sdk.query("crm.messenger.history", { threadId }); - return msgs.map((m) => this.fromDto(m)); + return Promise.all(msgs.map((m) => this.toDtoMessage(m))); } async send(threadId: string, content: string, opts?: SendOpts): Promise { + const att = opts?.attachment; if (this.socket) { const sendOpts = { ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), ...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}), + ...(att?.contentRef ? { attachment: { contentRef: att.contentRef, mimeType: att.mime, sizeBytes: att.sizeBytes ?? 0 } } : {}), }; const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined); - return this.fromKernel(m); + const msg = this.fromKernel(m); + // Reuse the staged attachment (already carries a display URL from upload) for instant render. + return att ? { ...msg, attachment: att } : msg; } const m = await this.sdk.command("crm.messenger.send", { threadId, content }); const msg = this.fromDto(m); this.polls.get(threadId)?.seen.add(msg.id); - return msg; + return att ? { ...msg, attachment: att } : msg; + } + + async upload(file: File): Promise { + const mime = file.type || "application/octet-stream"; + const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size }); + const res = await fetch(uploadUrl, { method: "PUT", body: file }); + if (!res.ok) throw new Error(`Upload failed (${res.status}).`); + const url = await this.downloadUrl(objectKey, mime); + return { url, mime, name: file.name, contentRef: objectKey, sizeBytes: file.size }; } subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe { @@ -265,6 +281,31 @@ export class CrmMessagingAdapter implements MessagingAdapter { return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt }; } + // ── attachments ──────────────────────────────────────────────── + /** A short-lived signed URL to display/download a stored object. */ + private async downloadUrl(contentRef: string, mime?: string): Promise { + const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", { contentRef, ...(mime ? { mime } : {}) }); + return url; + } + + private async resolveAttachment(a: { contentRef: string; mimeType: string; sizeBytes: number; filename?: string | null } | null | undefined): Promise { + if (!a?.contentRef) return undefined; + const url = await this.downloadUrl(a.contentRef, a.mimeType); + return { url, mime: a.mimeType, name: a.filename ?? "attachment", contentRef: a.contentRef, sizeBytes: a.sizeBytes }; + } + + private async toKernelMessage(m: KernelMessage): Promise { + const base = this.fromKernel(m); + const att = await this.resolveAttachment(m.attachment ?? null); + return att ? { ...base, attachment: att } : base; + } + + private async toDtoMessage(m: MessageDTO): Promise { + const base = this.fromDto(m); + const att = await this.resolveAttachment(m.attachment ?? null); + return att ? { ...base, attachment: att } : base; + } + // ── reaction state ───────────────────────────────────────────── private ingestReactions(m: KernelMessage): void { for (const a of m.annotations ?? []) {