feat(chat): add in-message attachments gallery and multi-file send
All checks were successful
CI / test (push) Successful in 19s
All checks were successful
CI / test (push) Successful in 19s
This commit is contained in:
@@ -3,11 +3,12 @@ import { createPortal } from "react-dom";
|
||||
import {
|
||||
deleteMessage,
|
||||
forwardMessageBulk,
|
||||
getChatAttachments,
|
||||
listMessageReactions,
|
||||
pinMessage,
|
||||
toggleMessageReaction
|
||||
} from "../api/chats";
|
||||
import type { Message, MessageReaction } from "../chat/types";
|
||||
import type { ChatAttachment, Message, MessageReaction } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { useAudioPlayerStore } from "../store/audioPlayerStore";
|
||||
@@ -66,6 +67,7 @@ export function MessageList() {
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
||||
const [undoTick, setUndoTick] = useState(0);
|
||||
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
||||
const [attachmentsByMessage, setAttachmentsByMessage] = useState<Record<number, ChatAttachment[]>>({});
|
||||
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -123,8 +125,43 @@ export function MessageList() {
|
||||
setForwardMessageId(null);
|
||||
setForwardSelectedChatIds(new Set());
|
||||
setReactionsByMessage({});
|
||||
setAttachmentsByMessage({});
|
||||
}, [activeChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChatId) {
|
||||
setAttachmentsByMessage({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const rows = await getChatAttachments(activeChatId, 400);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const grouped: Record<number, ChatAttachment[]> = {};
|
||||
for (const row of rows) {
|
||||
if (!grouped[row.message_id]) {
|
||||
grouped[row.message_id] = [];
|
||||
}
|
||||
grouped[row.message_id].push(row);
|
||||
}
|
||||
for (const key of Object.keys(grouped)) {
|
||||
grouped[Number(key)] = grouped[Number(key)].sort((a, b) => a.id - b.id);
|
||||
}
|
||||
setAttachmentsByMessage(grouped);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setAttachmentsByMessage({});
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeChatId, messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingDelete) {
|
||||
return;
|
||||
@@ -409,7 +446,12 @@ export function MessageList() {
|
||||
event.preventDefault();
|
||||
void ensureReactionsLoaded(message.id);
|
||||
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 220);
|
||||
setCtx({ x: pos.x, y: pos.y, messageId: message.id, attachmentUrl: getMessageAttachmentUrl(message) });
|
||||
setCtx({
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
messageId: message.id,
|
||||
attachmentUrl: getMessageAttachmentUrl(message, attachmentsByMessage[message.id] ?? []),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{selectedIds.size > 0 ? (
|
||||
@@ -448,6 +490,7 @@ export function MessageList() {
|
||||
) : null}
|
||||
|
||||
{renderMessageContent(message, {
|
||||
attachments: attachmentsByMessage[message.id] ?? [],
|
||||
onAttachmentContextMenu: (event, url) => {
|
||||
event.preventDefault();
|
||||
void ensureReactionsLoaded(message.id);
|
||||
@@ -455,10 +498,11 @@ export function MessageList() {
|
||||
setCtx({ x: pos.x, y: pos.y, messageId: message.id, attachmentUrl: url });
|
||||
},
|
||||
onOpenMedia: (url, type) => {
|
||||
const items = messages
|
||||
.filter((m) => (m.type === "image" || m.type === "video" || m.type === "circle_video") && !!m.text)
|
||||
.map((m) => ({ url: m.text as string, type: (m.type === "image" ? "image" : "video") as "image" | "video" }));
|
||||
const items = collectMediaItems(messages, attachmentsByMessage);
|
||||
const idx = items.findIndex((i) => i.url === url && i.type === type);
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
setMediaViewer({ items, index: idx >= 0 ? idx : 0 });
|
||||
},
|
||||
})}
|
||||
@@ -797,93 +841,186 @@ export function MessageList() {
|
||||
function renderMessageContent(
|
||||
message: Message,
|
||||
opts: {
|
||||
attachments: ChatAttachment[];
|
||||
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
|
||||
onOpenMedia: (url: string, type: "image" | "video") => void;
|
||||
}
|
||||
) {
|
||||
const messageType = message.type;
|
||||
const text = message.text;
|
||||
if (!text) return <p className="opacity-80">[empty]</p>;
|
||||
const legacyAttachment: ChatAttachment[] =
|
||||
text && /^https?:\/\//i.test(text)
|
||||
? [
|
||||
{
|
||||
id: -1,
|
||||
message_id: message.id,
|
||||
sender_id: message.sender_id,
|
||||
message_type: messageType,
|
||||
message_created_at: message.created_at,
|
||||
file_url: text,
|
||||
file_type: guessFileTypeByMessageType(messageType),
|
||||
file_size: 0,
|
||||
waveform_points: message.attachment_waveform ?? null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const attachments = opts.attachments.length > 0 ? opts.attachments : legacyAttachment;
|
||||
|
||||
if (messageType === "image") {
|
||||
if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
|
||||
const mediaItems = attachments
|
||||
.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/"))
|
||||
.map((item) => ({
|
||||
url: item.file_url,
|
||||
type: (item.file_type.startsWith("image/") ? "image" : "video") as "image" | "video",
|
||||
}));
|
||||
if (!mediaItems.length && text) {
|
||||
mediaItems.push({ url: text, type: messageType === "image" ? "image" : "video" });
|
||||
}
|
||||
if (!mediaItems.length) {
|
||||
return <p className="opacity-80">[empty]</p>;
|
||||
}
|
||||
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 (
|
||||
<button
|
||||
className="block overflow-hidden rounded-xl bg-slate-950/30"
|
||||
onClick={() => opts.onOpenMedia(text, "image")}
|
||||
onContextMenu={(event) => {
|
||||
event.stopPropagation();
|
||||
opts.onAttachmentContextMenu(event, text);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={text} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType === "video" || messageType === "circle_video") {
|
||||
return (
|
||||
<button
|
||||
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
|
||||
onClick={() => opts.onOpenMedia(text, "video")}
|
||||
onContextMenu={(event) => {
|
||||
event.stopPropagation();
|
||||
opts.onAttachmentContextMenu(event, text);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<video className="max-h-80 rounded-xl" muted src={text} />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85">▶</span>
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-1.5 rounded-xl" onContextMenu={(event) => event.stopPropagation()}>
|
||||
{mediaItems.slice(0, 6).map((item, index) => (
|
||||
<button
|
||||
className="relative overflow-hidden rounded-lg bg-slate-950/30"
|
||||
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-28 w-full object-cover md:h-36" 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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType === "voice") {
|
||||
const voiceItems = attachments.filter((item) => item.file_type.startsWith("audio/"));
|
||||
const items = voiceItems.length ? voiceItems : (text ? legacyAttachment : []);
|
||||
if (!items.length) {
|
||||
return <p className="opacity-80">[empty]</p>;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => {
|
||||
event.stopPropagation();
|
||||
opts.onAttachmentContextMenu(event, text);
|
||||
}}>
|
||||
<VoiceInlinePlayer src={text} title="Voice message" waveform={message.attachment_waveform ?? null} />
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2"
|
||||
key={`${item.file_url}-${index}`}
|
||||
onContextMenu={(event) => {
|
||||
event.stopPropagation();
|
||||
opts.onAttachmentContextMenu(event, item.file_url);
|
||||
}}
|
||||
>
|
||||
<VoiceInlinePlayer
|
||||
src={item.file_url}
|
||||
title="Voice message"
|
||||
waveform={item.waveform_points ?? message.attachment_waveform ?? null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType === "audio") {
|
||||
const audioItems = attachments.filter((item) => item.file_type.startsWith("audio/"));
|
||||
const items = audioItems.length ? audioItems : (text ? legacyAttachment : []);
|
||||
if (!items.length) {
|
||||
return <p className="opacity-80">[empty]</p>;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => {
|
||||
event.stopPropagation();
|
||||
opts.onAttachmentContextMenu(event, text);
|
||||
}}>
|
||||
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/25 text-emerald-200">🎵</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-slate-100">{extractFileName(text)}</p>
|
||||
<p className="text-[11px] text-slate-400">Audio</p>
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2"
|
||||
key={`${item.file_url}-${index}`}
|
||||
onContextMenu={(event) => {
|
||||
event.stopPropagation();
|
||||
opts.onAttachmentContextMenu(event, item.file_url);
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/25 text-emerald-200">🎵</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-slate-100">{extractFileName(item.file_url)}</p>
|
||||
<p className="text-[11px] text-slate-400">Audio</p>
|
||||
</div>
|
||||
</div>
|
||||
<AudioInlinePlayer src={item.file_url} title={extractFileName(item.file_url)} />
|
||||
</div>
|
||||
</div>
|
||||
<AudioInlinePlayer src={text} title={extractFileName(text)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messageType === "file") {
|
||||
return (
|
||||
<button
|
||||
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
|
||||
onContextMenu={(event) => {
|
||||
event.stopPropagation();
|
||||
opts.onAttachmentContextMenu(event, text);
|
||||
}}
|
||||
onClick={() => window.open(text, "_blank", "noopener,noreferrer")}
|
||||
type="button"
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Document</p>
|
||||
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(text)}</p>
|
||||
</button>
|
||||
const fileItems = attachments.length ? attachments : (text ? legacyAttachment : []);
|
||||
if (fileItems.length) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{fileItems.map((item, index) => (
|
||||
<button
|
||||
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
|
||||
key={`${item.file_url}-${index}`}
|
||||
onContextMenu={(event) => {
|
||||
event.stopPropagation();
|
||||
opts.onAttachmentContextMenu(event, item.file_url);
|
||||
}}
|
||||
onClick={() => window.open(item.file_url, "_blank", "noopener,noreferrer")}
|
||||
type="button"
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Document</p>
|
||||
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(item.file_url)}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return <p className="opacity-80">[empty]</p>;
|
||||
}
|
||||
return <p className="whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: formatMessageHtml(text) }} />;
|
||||
}
|
||||
|
||||
@@ -927,7 +1064,46 @@ function canDeleteForEveryone(
|
||||
return message.sender_id === meId;
|
||||
}
|
||||
|
||||
function getMessageAttachmentUrl(message: Message): string | null {
|
||||
function guessFileTypeByMessageType(messageType: Message["type"]): string {
|
||||
if (messageType === "image") return "image/jpeg";
|
||||
if (messageType === "video" || messageType === "circle_video") return "video/mp4";
|
||||
if (messageType === "audio" || messageType === "voice") return "audio/mpeg";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function collectMediaItems(
|
||||
messages: Message[],
|
||||
attachmentsByMessage: Record<number, ChatAttachment[]>
|
||||
): Array<{ url: string; type: "image" | "video" }> {
|
||||
const items: Array<{ url: string; type: "image" | "video" }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const attachments = attachmentsByMessage[message.id] ?? [];
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.file_url) continue;
|
||||
if (!attachment.file_type.startsWith("image/") && !attachment.file_type.startsWith("video/")) continue;
|
||||
const type = attachment.file_type.startsWith("image/") ? "image" : "video";
|
||||
const key = `${type}:${attachment.file_url}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push({ url: attachment.file_url, type });
|
||||
}
|
||||
if (attachments.length === 0 && message.text && /^https?:\/\//i.test(message.text)) {
|
||||
if (message.type !== "image" && message.type !== "video" && message.type !== "circle_video") continue;
|
||||
const type = message.type === "image" ? "image" : "video";
|
||||
const key = `${type}:${message.text}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
items.push({ url: message.text, type });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function getMessageAttachmentUrl(message: Message, attachments: ChatAttachment[]): string | null {
|
||||
if (attachments.length > 0 && attachments[0].file_url) {
|
||||
return attachments[0].file_url;
|
||||
}
|
||||
const mediaTypes = new Set(["image", "video", "audio", "voice", "file", "circle_video"]);
|
||||
if (!mediaTypes.has(message.type)) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user