feat(web): improve album layout and captions for multi-attachments
All checks were successful
CI / test (push) Successful in 31s

This commit is contained in:
2026-03-08 13:07:53 +03:00
parent d2dd9aa01b
commit 072677b9ad
3 changed files with 87 additions and 40 deletions

View File

@@ -83,7 +83,7 @@ export async function sendMessage(chatId: number, text: string, type: MessageTyp
export async function sendMessageWithClientId(
chatId: number,
text: string,
text: string | null,
type: MessageType,
clientMessageId: string,
replyToMessageId?: number

View File

@@ -508,24 +508,31 @@ export function MessageComposer() {
});
}
const kindSet = new Set(uploaded.map((item) => item.kind));
const inferredType: "file" | "image" | "video" | "audio" =
kindSet.size === 1 ? uploaded[0].kind : "file";
let inferredType: "file" | "image" | "video" | "audio" = "file";
if ([...kindSet].every((kind) => kind === "image")) {
inferredType = "image";
} else if ([...kindSet].every((kind) => kind === "image" || kind === "video")) {
inferredType = "video";
} else if ([...kindSet].every((kind) => kind === "audio")) {
inferredType = "audio";
} else if (kindSet.size === 1) {
inferredType = uploaded[0].kind;
}
const messageType =
inferredType === "video" && uploaded.length === 1 && sendAsCircle ? "circle_video" : inferredType;
const caption = captionDraft.trim() || null;
const fallbackText = uploaded[0]?.fileUrl ?? null;
const clientMessageId = makeClientMessageId();
addOptimisticMessage({
chatId: activeChatId,
senderId: me.id,
type: messageType,
text: caption || fallbackText,
text: caption,
clientMessageId,
});
const replyToMessageId = replyToByChat[activeChatId]?.id ?? undefined;
const created = await sendMessageWithClientId(
activeChatId,
caption || fallbackText || "",
caption,
messageType,
clientMessageId,
replyToMessageId

View File

@@ -865,6 +865,10 @@ function renderMessageContent(
]
: [];
const attachments = opts.attachments.length > 0 ? opts.attachments : legacyAttachment;
const captionText =
text && (!/^https?:\/\//i.test(text) || opts.attachments.length > 0)
? text.trim()
: "";
if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
const mediaItems = attachments
@@ -882,32 +886,9 @@ function renderMessageContent(
if (mediaItems.length === 1) {
const item = mediaItems[0];
return (
<button
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
onClick={() => opts.onOpenMedia(item.url, item.type)}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
type="button"
>
{item.type === "image" ? (
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={item.url} />
) : (
<>
<video className="max-h-80 rounded-xl" muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span>
</>
)}
</button>
);
}
return (
<div className="grid grid-cols-2 gap-1.5 rounded-xl" onContextMenu={(event) => event.stopPropagation()}>
{mediaItems.slice(0, 6).map((item, index) => (
<div className="space-y-1.5">
<button
className="relative overflow-hidden rounded-lg bg-slate-950/30"
key={`${item.url}-${index}`}
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
onClick={() => opts.onOpenMedia(item.url, item.type)}
onContextMenu={(event) => {
event.stopPropagation();
@@ -916,20 +897,57 @@ function renderMessageContent(
type="button"
>
{item.type === "image" ? (
<img alt="attachment" className="h-28 w-full object-cover md:h-36" draggable={false} src={item.url} />
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={item.url} />
) : (
<>
<video className="h-28 w-full object-cover md:h-36" muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-2xl text-white/85"></span>
<video className="max-h-80 rounded-xl" muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85"></span>
</>
)}
{index === 5 && mediaItems.length > 6 ? (
<span className="absolute inset-0 flex items-center justify-center bg-slate-950/60 text-lg font-semibold text-white">
+{mediaItems.length - 6}
</span>
) : null}
</button>
))}
{captionText ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null}
</div>
);
}
const gridClass = getMediaGridClass(mediaItems.length);
return (
<div className="space-y-1.5" onContextMenu={(event) => event.stopPropagation()}>
<div className={`grid gap-1.5 rounded-xl ${gridClass}`}>
{mediaItems.slice(0, 6).map((item, index) => {
const tileClass = getMediaTileClass(mediaItems.length, index);
return (
<button
className={`relative overflow-hidden rounded-lg bg-slate-950/30 ${tileClass}`}
key={`${item.url}-${index}`}
onClick={() => opts.onOpenMedia(item.url, item.type)}
onContextMenu={(event) => {
event.stopPropagation();
opts.onAttachmentContextMenu(event, item.url);
}}
type="button"
>
{item.type === "image" ? (
<img alt="attachment" className="h-full w-full object-cover" draggable={false} src={item.url} />
) : (
<>
<video className="h-full w-full object-cover" muted src={item.url} />
<span className="absolute inset-0 flex items-center justify-center text-2xl text-white/85"></span>
</>
)}
{index === 5 && mediaItems.length > 6 ? (
<span className="absolute inset-0 flex items-center justify-center bg-slate-950/60 text-lg font-semibold text-white">
+{mediaItems.length - 6}
</span>
) : null}
</button>
);
})}
</div>
{captionText ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null}
</div>
);
}
@@ -1013,6 +1031,9 @@ function renderMessageContent(
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(item.file_url)}</p>
</button>
))}
{captionText ? (
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
) : null}
</div>
);
}
@@ -1100,6 +1121,25 @@ function collectMediaItems(
return items;
}
function getMediaGridClass(count: number): string {
if (count <= 1) return "grid-cols-1";
if (count === 2) return "grid-cols-2";
return "grid-cols-2 auto-rows-[90px] md:auto-rows-[120px]";
}
function getMediaTileClass(count: number, index: number): string {
if (count <= 2) {
return "h-32 md:h-40";
}
if (count === 3 && index === 0) {
return "row-span-2 h-full";
}
if (count === 5 && index === 0) {
return "row-span-2 h-full";
}
return "h-full";
}
function getMessageAttachmentUrl(message: Message, attachments: ChatAttachment[]): string | null {
if (attachments.length > 0 && attachments[0].file_url) {
return attachments[0].file_url;