feat(web): improve album layout and captions for multi-attachments
All checks were successful
CI / test (push) Successful in 31s
All checks were successful
CI / test (push) Successful in 31s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user