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(
|
export async function sendMessageWithClientId(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
text: string,
|
text: string | null,
|
||||||
type: MessageType,
|
type: MessageType,
|
||||||
clientMessageId: string,
|
clientMessageId: string,
|
||||||
replyToMessageId?: number
|
replyToMessageId?: number
|
||||||
|
|||||||
@@ -508,24 +508,31 @@ export function MessageComposer() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const kindSet = new Set(uploaded.map((item) => item.kind));
|
const kindSet = new Set(uploaded.map((item) => item.kind));
|
||||||
const inferredType: "file" | "image" | "video" | "audio" =
|
let inferredType: "file" | "image" | "video" | "audio" = "file";
|
||||||
kindSet.size === 1 ? uploaded[0].kind : "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 =
|
const messageType =
|
||||||
inferredType === "video" && uploaded.length === 1 && sendAsCircle ? "circle_video" : inferredType;
|
inferredType === "video" && uploaded.length === 1 && sendAsCircle ? "circle_video" : inferredType;
|
||||||
const caption = captionDraft.trim() || null;
|
const caption = captionDraft.trim() || null;
|
||||||
const fallbackText = uploaded[0]?.fileUrl ?? null;
|
|
||||||
const clientMessageId = makeClientMessageId();
|
const clientMessageId = makeClientMessageId();
|
||||||
addOptimisticMessage({
|
addOptimisticMessage({
|
||||||
chatId: activeChatId,
|
chatId: activeChatId,
|
||||||
senderId: me.id,
|
senderId: me.id,
|
||||||
type: messageType,
|
type: messageType,
|
||||||
text: caption || fallbackText,
|
text: caption,
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
});
|
});
|
||||||
const replyToMessageId = replyToByChat[activeChatId]?.id ?? undefined;
|
const replyToMessageId = replyToByChat[activeChatId]?.id ?? undefined;
|
||||||
const created = await sendMessageWithClientId(
|
const created = await sendMessageWithClientId(
|
||||||
activeChatId,
|
activeChatId,
|
||||||
caption || fallbackText || "",
|
caption,
|
||||||
messageType,
|
messageType,
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
replyToMessageId
|
replyToMessageId
|
||||||
|
|||||||
@@ -865,6 +865,10 @@ function renderMessageContent(
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
const attachments = opts.attachments.length > 0 ? opts.attachments : legacyAttachment;
|
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") {
|
if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
|
||||||
const mediaItems = attachments
|
const mediaItems = attachments
|
||||||
@@ -882,32 +886,9 @@ function renderMessageContent(
|
|||||||
if (mediaItems.length === 1) {
|
if (mediaItems.length === 1) {
|
||||||
const item = mediaItems[0];
|
const item = mediaItems[0];
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="space-y-1.5">
|
||||||
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) => (
|
|
||||||
<button
|
<button
|
||||||
className="relative overflow-hidden rounded-lg bg-slate-950/30"
|
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
|
||||||
key={`${item.url}-${index}`}
|
|
||||||
onClick={() => opts.onOpenMedia(item.url, item.type)}
|
onClick={() => opts.onOpenMedia(item.url, item.type)}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -916,20 +897,57 @@ function renderMessageContent(
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{item.type === "image" ? (
|
{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} />
|
<video className="max-h-80 rounded-xl" muted src={item.url} />
|
||||||
<span className="absolute inset-0 flex items-center justify-center text-2xl text-white/85">▶</span>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1013,6 +1031,9 @@ function renderMessageContent(
|
|||||||
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(item.file_url)}</p>
|
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(item.file_url)}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{captionText ? (
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1100,6 +1121,25 @@ function collectMediaItems(
|
|||||||
return items;
|
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 {
|
function getMessageAttachmentUrl(message: Message, attachments: ChatAttachment[]): string | null {
|
||||||
if (attachments.length > 0 && attachments[0].file_url) {
|
if (attachments.length > 0 && attachments[0].file_url) {
|
||||||
return attachments[0].file_url;
|
return attachments[0].file_url;
|
||||||
|
|||||||
Reference in New Issue
Block a user