diff --git a/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts b/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts index d5518a3530f..d7eb2191b59 100644 --- a/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts +++ b/apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts @@ -1,6 +1,7 @@ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { messageAttachmentsOrderBy } from "@/lib/messages/utils"; import { prisma } from "@/lib/prisma"; import { sendBatchEmail } from "@dub/email"; import NewMessageFromProgram from "@dub/email/templates/new-message-from-program"; @@ -55,6 +56,7 @@ export async function POST(req: Request) { include: { senderUser: true, attachments: { + orderBy: messageAttachmentsOrderBy, select: { name: true, size: true, diff --git a/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts b/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts index 6300694bce5..b862b30e100 100644 --- a/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts +++ b/apps/web/app/(ee)/api/cron/messages/notify-program/route.ts @@ -1,6 +1,7 @@ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { messageAttachmentsOrderBy } from "@/lib/messages/utils"; import { prisma } from "@/lib/prisma"; import { sendBatchEmail } from "@dub/email"; import NewMessageFromPartner from "@dub/email/templates/new-message-from-partner"; @@ -57,6 +58,7 @@ export async function POST(req: Request) { include: { senderPartner: true, attachments: { + orderBy: messageAttachmentsOrderBy, select: { name: true, size: true, diff --git a/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts b/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts index 27021e08c95..813187abd00 100644 --- a/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts +++ b/apps/web/app/(ee)/api/intercom/webhook/process/conversation-admin-replied.ts @@ -10,7 +10,10 @@ import { intercomCredentialsSchema, } from "@/lib/integrations/intercom/schema"; import { PROGRAM_ALLOWED_ATTACHMENT_TYPES } from "@/lib/messages/constants"; -import { sanitizeFileName } from "@/lib/messages/utils"; +import { + mapMessageAttachmentsForCreate, + sanitizeFileName, +} from "@/lib/messages/utils"; import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { @@ -112,13 +115,7 @@ export async function handleConversationAdminReplied({ text: originalMessage, ...(storedAttachments.length > 0 && { attachments: { - create: storedAttachments.map((attachment) => ({ - id: createId({ prefix: "msa_" }), - storageKey: attachment.storageKey, - name: attachment.name, - size: attachment.size, - type: attachment.type, - })), + create: mapMessageAttachmentsForCreate(storedAttachments), }, }), }, diff --git a/apps/web/app/(ee)/api/messages/route.ts b/apps/web/app/(ee)/api/messages/route.ts index 7082e977ae9..33b00f8296f 100644 --- a/apps/web/app/(ee)/api/messages/route.ts +++ b/apps/web/app/(ee)/api/messages/route.ts @@ -6,6 +6,7 @@ import { PartnerMessagesSchema, getPartnerMessagesQuerySchema, } from "@/lib/messages/schemas"; +import { messageAttachmentsOrderBy } from "@/lib/messages/utils"; import { prisma } from "@/lib/prisma"; import { NextResponse } from "next/server"; @@ -47,7 +48,9 @@ export const GET = withWorkspace( include: { senderPartner: true, senderUser: true, - attachments: true, + attachments: { + orderBy: messageAttachmentsOrderBy, + }, }, orderBy: { [sortBy]: sortOrder, diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index 163e0a3b2de..ba9a69e5611 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -4,6 +4,7 @@ import { ProgramMessagesSchema, getProgramMessagesQuerySchema, } from "@/lib/messages/schemas"; +import { messageAttachmentsOrderBy } from "@/lib/messages/utils"; import { prisma } from "@/lib/prisma"; import { NextResponse } from "next/server"; @@ -78,7 +79,9 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { include: { senderPartner: true, senderUser: true, - attachments: true, + attachments: { + orderBy: messageAttachmentsOrderBy, + }, }, orderBy: { [sortBy]: sortOrder, diff --git a/apps/web/lib/messages/enrich.ts b/apps/web/lib/messages/enrich.ts index 48182f08537..7eb02c8fd4f 100644 --- a/apps/web/lib/messages/enrich.ts +++ b/apps/web/lib/messages/enrich.ts @@ -1,12 +1,15 @@ +import { sortMessageAttachments } from "@/lib/messages/utils"; import { storage } from "@/lib/storage"; import { Message, MessageAttachment } from "@prisma/client"; export async function enrichMessage( message: Message & { attachments: MessageAttachment[] }, ) { + const attachments = sortMessageAttachments(message.attachments); + return { ...message, - attachments: await Promise.all(message.attachments.map(enrichAttachment)), + attachments: await Promise.all(attachments.map(enrichAttachment)), }; } diff --git a/apps/web/lib/messages/message-partner.ts b/apps/web/lib/messages/message-partner.ts index 9a532dc2c6b..20a17e791c1 100644 --- a/apps/web/lib/messages/message-partner.ts +++ b/apps/web/lib/messages/message-partner.ts @@ -14,6 +14,10 @@ import * as z from "zod/v4"; import { authActionClient } from "../actions/safe-action"; import { throwIfNoPermission } from "../actions/throw-if-no-permission"; import { MessageSchema, messagePartnerSchema } from "./schemas"; +import { + mapMessageAttachmentsForCreate, + messageAttachmentsOrderBy, +} from "./utils"; const schema = messagePartnerSchema .extend({ @@ -136,20 +140,16 @@ export const messagePartnerAction = authActionClient text, ...(attachments.length > 0 && { attachments: { - create: attachments.map((att) => ({ - id: createId({ prefix: "msa_" }), - storageKey: att.storageKey, - name: att.name, - size: att.size, - type: att.type, - })), + create: mapMessageAttachmentsForCreate(attachments), }, }), }, include: { senderUser: true, senderPartner: true, - attachments: true, + attachments: { + orderBy: messageAttachmentsOrderBy, + }, }, }); diff --git a/apps/web/lib/messages/message-program.ts b/apps/web/lib/messages/message-program.ts index 4e478d74fac..d040dc94224 100644 --- a/apps/web/lib/messages/message-program.ts +++ b/apps/web/lib/messages/message-program.ts @@ -8,6 +8,10 @@ import { APP_DOMAIN_WITH_NGROK, INTERCOM_INTEGRATION_ID } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { authPartnerActionClient } from "../actions/safe-action"; import { MessageSchema, messageProgramSchema } from "./schemas"; +import { + mapMessageAttachmentsForCreate, + messageAttachmentsOrderBy, +} from "./utils"; const schema = messageProgramSchema.refine( (data) => data.text.trim().length > 0 || data.attachments.length > 0, @@ -81,20 +85,16 @@ export const messageProgramAction = authPartnerActionClient text, ...(attachments.length > 0 && { attachments: { - create: attachments.map((att) => ({ - id: createId({ prefix: "msa_" }), - storageKey: att.storageKey, - name: att.name, - size: att.size, - type: att.type, - })), + create: mapMessageAttachmentsForCreate(attachments), }, }), }, include: { senderUser: true, senderPartner: true, - attachments: true, + attachments: { + orderBy: messageAttachmentsOrderBy, + }, }, }); diff --git a/apps/web/lib/messages/utils.ts b/apps/web/lib/messages/utils.ts index 1af9b366f82..edbf8ddc751 100644 --- a/apps/web/lib/messages/utils.ts +++ b/apps/web/lib/messages/utils.ts @@ -1,8 +1,42 @@ +import { createId } from "@/lib/api/create-id"; import { ATTACHMENT_MIME_TYPE_LABELS, PREVIEWABLE_IMAGE_TYPES, } from "./constants"; +export const messageAttachmentsOrderBy = { createdAt: "asc" } as const; + +type MessageAttachmentInput = { + storageKey: string; + name: string; + size: number; + type: string; +}; + +export function mapMessageAttachmentsForCreate( + attachments: MessageAttachmentInput[], +) { + const base = Date.now(); + + return attachments.map((att, index) => ({ + id: createId({ prefix: "msa_" }), + storageKey: att.storageKey, + name: att.name, + size: att.size, + type: att.type, + createdAt: new Date(base + index), + })); +} + +export function sortMessageAttachments< + T extends { createdAt: Date; id: string }, +>(attachments: T[]) { + return [...attachments].sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id), + ); +} + // Normalize a user-supplied file name export function sanitizeFileName(name: string): string { return name diff --git a/apps/web/ui/messages/message-attachments.tsx b/apps/web/ui/messages/message-attachments.tsx index 3e7fc954a4c..e966467aa22 100644 --- a/apps/web/ui/messages/message-attachments.tsx +++ b/apps/web/ui/messages/message-attachments.tsx @@ -1,6 +1,10 @@ "use client"; -import { getAttachmentTypeLabel } from "@/lib/messages/utils"; +import { + getAttachmentTypeLabel, + isPreviewableImageType, + sortMessageAttachments, +} from "@/lib/messages/utils"; import { MessageAttachment } from "@/lib/types"; import { formatFileSize } from "@dub/utils"; import { cn } from "@dub/utils/src"; @@ -36,82 +40,105 @@ export const ATTACHMENT_MIME_TYPE_COLOR: Record = { "image/webp": "bg-blue-500", }; -export function MessageImageAttachments({ +export function MessageAttachmentsList({ attachments, + isMySide, }: { attachments: MessageAttachment[]; + isMySide: boolean; }) { + if (attachments.length === 0) { + return null; + } + + const sortedAttachments = sortMessageAttachments(attachments); + return ( -
- {attachments.map((img) => ( -
- {img.signedUrl ? ( - <> - - - - ) : ( -
- )} -
- ))} +
+ {sortedAttachments.map((attachment) => + isPreviewableImageType(attachment.type) ? ( + + ) : ( + + ), + )}
); } -export function MessageFileAttachments({ - attachments, +function MessageImageAttachment({ + attachment, }: { - attachments: MessageAttachment[]; + attachment: MessageAttachment; }) { return ( -
- {attachments.map((file) => ( - - ))} +
+ {attachment.signedUrl ? ( + <> + + + + ) : ( +
+ )}
); } +function MessageFileAttachment({ + attachment, +}: { + attachment: MessageAttachment; +}) { + return ( + + ); +} + function FileTypeBadge({ type }: { type: string }) { return (
)} {/* Attachments — rendered outside the bubble */} - {(() => { - const imageAttachments = - message.attachments?.filter((a) => - isPreviewableImageType(a.type), - ) ?? []; - const fileAttachments = - message.attachments?.filter( - (a) => !isPreviewableImageType(a.type), - ) ?? []; - - return ( - <> - {imageAttachments.length > 0 && ( - - )} - {fileAttachments.length > 0 && ( - - )} - - ); - })()} + {message.attachments && + message.attachments.length > 0 && ( + + )}
)}