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,8 +3,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.auth.service import get_current_user
|
from app.auth.service import get_current_user
|
||||||
from app.database.session import get_db
|
from app.database.session import get_db
|
||||||
|
from app.messages.repository import get_message_by_id
|
||||||
from app.media.schemas import AttachmentCreateRequest, AttachmentRead, ChatAttachmentRead, UploadUrlRequest, UploadUrlResponse
|
from app.media.schemas import AttachmentCreateRequest, AttachmentRead, ChatAttachmentRead, UploadUrlRequest, UploadUrlResponse
|
||||||
from app.media.service import generate_upload_url, list_attachments_for_chat, store_attachment_metadata
|
from app.media.service import generate_upload_url, list_attachments_for_chat, store_attachment_metadata
|
||||||
|
from app.realtime.service import realtime_gateway
|
||||||
from app.users.models import User
|
from app.users.models import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/media", tags=["media"])
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
@@ -24,7 +26,11 @@ async def create_attachment_metadata(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> AttachmentRead:
|
) -> AttachmentRead:
|
||||||
return await store_attachment_metadata(db, user_id=current_user.id, payload=payload)
|
attachment = await store_attachment_metadata(db, user_id=current_user.id, payload=payload)
|
||||||
|
message = await get_message_by_id(db, attachment.message_id)
|
||||||
|
if message:
|
||||||
|
await realtime_gateway.publish_chat_updated(chat_id=message.chat_id)
|
||||||
|
return attachment
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chats/{chat_id}/attachments", response_model=list[ChatAttachmentRead])
|
@router.get("/chats/{chat_id}/attachments", response_model=list[ChatAttachmentRead])
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function MessageComposer() {
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
||||||
const [sendAsCircle, setSendAsCircle] = useState(false);
|
const [sendAsCircle, setSendAsCircle] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
@@ -456,57 +456,106 @@ export function MessageComposer() {
|
|||||||
window.addEventListener("pointercancel", onPointerCancel, { once: true });
|
window.addEventListener("pointercancel", onPointerCancel, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(file: File) {
|
function selectFiles(files: File[]) {
|
||||||
|
if (!files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
setSelectedFile(file);
|
setSelectedFiles(files);
|
||||||
setShowAttachMenu(false);
|
setShowAttachMenu(false);
|
||||||
const fileType = inferType(file);
|
const fileType = inferType(files[0]);
|
||||||
setSelectedType(fileType);
|
setSelectedType(fileType);
|
||||||
setSendAsCircle(false);
|
setSendAsCircle(false);
|
||||||
if (previewUrl) {
|
if (previewUrl) {
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
if (fileType === "image" || fileType === "video") {
|
if (files.length === 1 && (fileType === "image" || fileType === "video")) {
|
||||||
setPreviewUrl(URL.createObjectURL(file));
|
setPreviewUrl(URL.createObjectURL(files[0]));
|
||||||
} else {
|
} else {
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendSelectedFile() {
|
async function sendSelectedFiles() {
|
||||||
if (!selectedFile) {
|
if (!selectedFiles.length || !activeChatId || !me) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const uploadFile = await prepareFileForUpload(selectedFile, selectedType);
|
setIsUploading(true);
|
||||||
const messageType = selectedType === "video" && sendAsCircle ? "circle_video" : selectedType;
|
setUploadError(null);
|
||||||
await handleUpload(uploadFile, messageType);
|
setUploadProgress(0);
|
||||||
if (captionDraft.trim() && activeChatId && me) {
|
const prepared = await Promise.all(
|
||||||
const clientMessageId = makeClientMessageId();
|
selectedFiles.map(async (file) => {
|
||||||
const textValue = captionDraft.trim();
|
const kind = inferType(file);
|
||||||
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId });
|
const uploadFile = await prepareFileForUpload(file, kind);
|
||||||
try {
|
return { file: uploadFile, kind };
|
||||||
const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId);
|
})
|
||||||
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
);
|
||||||
} catch {
|
const uploaded: Array<{ fileUrl: string; fileType: string; fileSize: number; kind: "file" | "image" | "video" | "audio" }> = [];
|
||||||
removeOptimisticMessage(activeChatId, clientMessageId);
|
try {
|
||||||
|
for (let index = 0; index < prepared.length; index += 1) {
|
||||||
|
const current = prepared[index];
|
||||||
|
const upload = await requestUploadUrl(current.file);
|
||||||
|
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, current.file, (percent) => {
|
||||||
|
const done = index / prepared.length;
|
||||||
|
const currentPart = (percent / 100) / prepared.length;
|
||||||
|
setUploadProgress(Math.min(100, Math.round((done + currentPart) * 100)));
|
||||||
|
});
|
||||||
|
uploaded.push({
|
||||||
|
fileUrl: upload.file_url,
|
||||||
|
fileType: current.file.type || "application/octet-stream",
|
||||||
|
fileSize: current.file.size,
|
||||||
|
kind: current.kind,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
const kindSet = new Set(uploaded.map((item) => item.kind));
|
||||||
|
const inferredType: "file" | "image" | "video" | "audio" =
|
||||||
|
kindSet.size === 1 ? uploaded[0].kind : "file";
|
||||||
|
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,
|
||||||
|
clientMessageId,
|
||||||
|
});
|
||||||
|
const replyToMessageId = replyToByChat[activeChatId]?.id ?? undefined;
|
||||||
|
const created = await sendMessageWithClientId(
|
||||||
|
activeChatId,
|
||||||
|
caption || fallbackText || "",
|
||||||
|
messageType,
|
||||||
|
clientMessageId,
|
||||||
|
replyToMessageId
|
||||||
|
);
|
||||||
|
confirmMessageByClientId(activeChatId, clientMessageId, created);
|
||||||
|
for (const item of uploaded) {
|
||||||
|
await attachFile(created.id, item.fileUrl, item.fileType, item.fileSize);
|
||||||
|
}
|
||||||
|
setReplyToMessage(activeChatId, null);
|
||||||
|
} catch {
|
||||||
|
setUploadError("Upload failed. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
}
|
}
|
||||||
if (previewUrl) {
|
if (previewUrl) {
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
setSelectedFile(null);
|
setSelectedFiles([]);
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setCaptionDraft("");
|
setCaptionDraft("");
|
||||||
setSelectedType("file");
|
setSelectedType("file");
|
||||||
setSendAsCircle(false);
|
setSendAsCircle(false);
|
||||||
setUploadProgress(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelSelectedFile() {
|
function cancelSelectedFile() {
|
||||||
if (previewUrl) {
|
if (previewUrl) {
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
setSelectedFile(null);
|
setSelectedFiles([]);
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setCaptionDraft("");
|
setCaptionDraft("");
|
||||||
setSelectedType("file");
|
setSelectedType("file");
|
||||||
@@ -670,12 +719,13 @@ export function MessageComposer() {
|
|||||||
ref={mediaInputRef}
|
ref={mediaInputRef}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept="image/*,video/*"
|
accept="image/*,video/*"
|
||||||
disabled={isUploading || recordingState !== "idle"}
|
disabled={isUploading || recordingState !== "idle"}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const files = event.target.files ? Array.from(event.target.files) : [];
|
||||||
if (file) {
|
if (files.length) {
|
||||||
selectFile(file);
|
selectFiles(files);
|
||||||
}
|
}
|
||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
}}
|
}}
|
||||||
@@ -684,11 +734,12 @@ export function MessageComposer() {
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
disabled={isUploading || recordingState !== "idle"}
|
disabled={isUploading || recordingState !== "idle"}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const files = event.target.files ? Array.from(event.target.files) : [];
|
||||||
if (file) {
|
if (files.length) {
|
||||||
selectFile(file);
|
selectFiles(files);
|
||||||
}
|
}
|
||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
}}
|
}}
|
||||||
@@ -747,13 +798,17 @@ export function MessageComposer() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedFile ? (
|
{selectedFiles.length > 0 ? (
|
||||||
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/80 p-3" onClick={cancelSelectedFile}>
|
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/80 p-3" onClick={cancelSelectedFile}>
|
||||||
<div className="flex h-full w-full max-w-2xl flex-col rounded-2xl border border-slate-700/80 bg-slate-900/95 shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
<div className="flex h-full w-full max-w-2xl flex-col rounded-2xl border border-slate-700/80 bg-slate-900/95 shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between border-b border-slate-700/70 px-3 py-2">
|
<div className="flex items-center justify-between border-b border-slate-700/70 px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold">Send {selectedType === "image" || selectedType === "video" ? "Photo" : "File"}</p>
|
<p className="truncate text-sm font-semibold">Send {selectedFiles.length > 1 ? "Attachments" : (selectedType === "image" || selectedType === "video" ? "Photo" : "File")}</p>
|
||||||
<p className="truncate text-xs text-slate-400">{selectedFile.name} • {formatBytes(selectedFile.size)}</p>
|
<p className="truncate text-xs text-slate-400">
|
||||||
|
{selectedFiles.length === 1
|
||||||
|
? `${selectedFiles[0].name} • ${formatBytes(selectedFiles[0].size)}`
|
||||||
|
: `${selectedFiles.length} files • ${formatBytes(selectedFiles.reduce((acc, file) => acc + file.size, 0))}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={cancelSelectedFile} type="button">
|
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={cancelSelectedFile} type="button">
|
||||||
Close
|
Close
|
||||||
@@ -761,15 +816,18 @@ export function MessageComposer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tg-scrollbar min-h-0 flex-1 overflow-auto p-3">
|
<div className="tg-scrollbar min-h-0 flex-1 overflow-auto p-3">
|
||||||
{previewUrl && selectedType === "image" ? (
|
{previewUrl && selectedType === "image" && selectedFiles.length === 1 ? (
|
||||||
<img className="mx-auto max-h-[70vh] w-full rounded-xl object-contain" src={previewUrl} alt={selectedFile.name} />
|
<img className="mx-auto max-h-[70vh] w-full rounded-xl object-contain" src={previewUrl} alt={selectedFiles[0].name} />
|
||||||
) : null}
|
) : null}
|
||||||
{previewUrl && selectedType === "video" ? (
|
{previewUrl && selectedType === "video" && selectedFiles.length === 1 ? (
|
||||||
<video className="mx-auto max-h-[70vh] w-full rounded-xl" src={previewUrl} controls muted />
|
<video className="mx-auto max-h-[70vh] w-full rounded-xl" src={previewUrl} controls muted />
|
||||||
) : null}
|
) : null}
|
||||||
{!previewUrl ? (
|
{!previewUrl ? (
|
||||||
<div className="rounded-lg border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-xs text-slate-300">
|
<div className="rounded-lg border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-xs text-slate-300">
|
||||||
No preview available
|
{selectedFiles.slice(0, 8).map((file) => (
|
||||||
|
<p className="truncate" key={`${file.name}-${file.size}`}>{file.name}</p>
|
||||||
|
))}
|
||||||
|
{selectedFiles.length > 8 ? <p className="mt-1 text-slate-400">+{selectedFiles.length - 8} more</p> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -782,7 +840,7 @@ export function MessageComposer() {
|
|||||||
value={captionDraft}
|
value={captionDraft}
|
||||||
onChange={(event) => setCaptionDraft(event.target.value)}
|
onChange={(event) => setCaptionDraft(event.target.value)}
|
||||||
/>
|
/>
|
||||||
{selectedType === "video" ? (
|
{selectedType === "video" && selectedFiles.length === 1 ? (
|
||||||
<label className="mb-2 flex items-center gap-2 text-xs text-slate-300">
|
<label className="mb-2 flex items-center gap-2 text-xs text-slate-300">
|
||||||
<input
|
<input
|
||||||
checked={sendAsCircle}
|
checked={sendAsCircle}
|
||||||
@@ -804,7 +862,7 @@ export function MessageComposer() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="w-full rounded-xl bg-sky-500 px-3 py-2 font-semibold text-slate-950 disabled:opacity-50"
|
className="w-full rounded-xl bg-sky-500 px-3 py-2 font-semibold text-slate-950 disabled:opacity-50"
|
||||||
onClick={() => void sendSelectedFile()}
|
onClick={() => void sendSelectedFiles()}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
>
|
>
|
||||||
Send
|
Send
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { createPortal } from "react-dom";
|
|||||||
import {
|
import {
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
forwardMessageBulk,
|
forwardMessageBulk,
|
||||||
|
getChatAttachments,
|
||||||
listMessageReactions,
|
listMessageReactions,
|
||||||
pinMessage,
|
pinMessage,
|
||||||
toggleMessageReaction
|
toggleMessageReaction
|
||||||
} from "../api/chats";
|
} from "../api/chats";
|
||||||
import type { Message, MessageReaction } from "../chat/types";
|
import type { ChatAttachment, Message, MessageReaction } from "../chat/types";
|
||||||
import { useAuthStore } from "../store/authStore";
|
import { useAuthStore } from "../store/authStore";
|
||||||
import { useChatStore } from "../store/chatStore";
|
import { useChatStore } from "../store/chatStore";
|
||||||
import { useAudioPlayerStore } from "../store/audioPlayerStore";
|
import { useAudioPlayerStore } from "../store/audioPlayerStore";
|
||||||
@@ -66,6 +67,7 @@ export function MessageList() {
|
|||||||
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
||||||
const [undoTick, setUndoTick] = useState(0);
|
const [undoTick, setUndoTick] = useState(0);
|
||||||
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
||||||
|
const [attachmentsByMessage, setAttachmentsByMessage] = useState<Record<number, ChatAttachment[]>>({});
|
||||||
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
|
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -123,8 +125,43 @@ export function MessageList() {
|
|||||||
setForwardMessageId(null);
|
setForwardMessageId(null);
|
||||||
setForwardSelectedChatIds(new Set());
|
setForwardSelectedChatIds(new Set());
|
||||||
setReactionsByMessage({});
|
setReactionsByMessage({});
|
||||||
|
setAttachmentsByMessage({});
|
||||||
}, [activeChatId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!pendingDelete) {
|
if (!pendingDelete) {
|
||||||
return;
|
return;
|
||||||
@@ -409,7 +446,12 @@ export function MessageList() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void ensureReactionsLoaded(message.id);
|
void ensureReactionsLoaded(message.id);
|
||||||
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 220);
|
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 ? (
|
{selectedIds.size > 0 ? (
|
||||||
@@ -448,6 +490,7 @@ export function MessageList() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{renderMessageContent(message, {
|
{renderMessageContent(message, {
|
||||||
|
attachments: attachmentsByMessage[message.id] ?? [],
|
||||||
onAttachmentContextMenu: (event, url) => {
|
onAttachmentContextMenu: (event, url) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void ensureReactionsLoaded(message.id);
|
void ensureReactionsLoaded(message.id);
|
||||||
@@ -455,10 +498,11 @@ export function MessageList() {
|
|||||||
setCtx({ x: pos.x, y: pos.y, messageId: message.id, attachmentUrl: url });
|
setCtx({ x: pos.x, y: pos.y, messageId: message.id, attachmentUrl: url });
|
||||||
},
|
},
|
||||||
onOpenMedia: (url, type) => {
|
onOpenMedia: (url, type) => {
|
||||||
const items = messages
|
const items = collectMediaItems(messages, attachmentsByMessage);
|
||||||
.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 idx = items.findIndex((i) => i.url === url && i.type === type);
|
const idx = items.findIndex((i) => i.url === url && i.type === type);
|
||||||
|
if (!items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setMediaViewer({ items, index: idx >= 0 ? idx : 0 });
|
setMediaViewer({ items, index: idx >= 0 ? idx : 0 });
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
@@ -797,93 +841,186 @@ export function MessageList() {
|
|||||||
function renderMessageContent(
|
function renderMessageContent(
|
||||||
message: Message,
|
message: Message,
|
||||||
opts: {
|
opts: {
|
||||||
|
attachments: ChatAttachment[];
|
||||||
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
|
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
|
||||||
onOpenMedia: (url: string, type: "image" | "video") => void;
|
onOpenMedia: (url: string, type: "image" | "video") => void;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const messageType = message.type;
|
const messageType = message.type;
|
||||||
const text = message.text;
|
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 (
|
return (
|
||||||
<button
|
<div className="grid grid-cols-2 gap-1.5 rounded-xl" onContextMenu={(event) => event.stopPropagation()}>
|
||||||
className="block overflow-hidden rounded-xl bg-slate-950/30"
|
{mediaItems.slice(0, 6).map((item, index) => (
|
||||||
onClick={() => opts.onOpenMedia(text, "image")}
|
<button
|
||||||
onContextMenu={(event) => {
|
className="relative overflow-hidden rounded-lg bg-slate-950/30"
|
||||||
event.stopPropagation();
|
key={`${item.url}-${index}`}
|
||||||
opts.onAttachmentContextMenu(event, text);
|
onClick={() => opts.onOpenMedia(item.url, item.type)}
|
||||||
}}
|
onContextMenu={(event) => {
|
||||||
type="button"
|
event.stopPropagation();
|
||||||
>
|
opts.onAttachmentContextMenu(event, item.url);
|
||||||
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={text} />
|
}}
|
||||||
</button>
|
type="button"
|
||||||
);
|
>
|
||||||
}
|
{item.type === "image" ? (
|
||||||
|
<img alt="attachment" className="h-28 w-full object-cover md:h-36" draggable={false} src={item.url} />
|
||||||
if (messageType === "video" || messageType === "circle_video") {
|
) : (
|
||||||
return (
|
<>
|
||||||
<button
|
<video className="h-28 w-full object-cover md:h-36" muted src={item.url} />
|
||||||
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
|
<span className="absolute inset-0 flex items-center justify-center text-2xl text-white/85">▶</span>
|
||||||
onClick={() => opts.onOpenMedia(text, "video")}
|
</>
|
||||||
onContextMenu={(event) => {
|
)}
|
||||||
event.stopPropagation();
|
{index === 5 && mediaItems.length > 6 ? (
|
||||||
opts.onAttachmentContextMenu(event, text);
|
<span className="absolute inset-0 flex items-center justify-center bg-slate-950/60 text-lg font-semibold text-white">
|
||||||
}}
|
+{mediaItems.length - 6}
|
||||||
type="button"
|
</span>
|
||||||
>
|
) : null}
|
||||||
<video className="max-h-80 rounded-xl" muted src={text} />
|
</button>
|
||||||
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85">▶</span>
|
))}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageType === "voice") {
|
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 (
|
return (
|
||||||
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => {
|
<div className="space-y-1.5">
|
||||||
event.stopPropagation();
|
{items.map((item, index) => (
|
||||||
opts.onAttachmentContextMenu(event, text);
|
<div
|
||||||
}}>
|
className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2"
|
||||||
<VoiceInlinePlayer src={text} title="Voice message" waveform={message.attachment_waveform ?? null} />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageType === "audio") {
|
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 (
|
return (
|
||||||
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => {
|
<div className="space-y-1.5">
|
||||||
event.stopPropagation();
|
{items.map((item, index) => (
|
||||||
opts.onAttachmentContextMenu(event, text);
|
<div
|
||||||
}}>
|
className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2"
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
|
key={`${item.file_url}-${index}`}
|
||||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/25 text-emerald-200">🎵</span>
|
onContextMenu={(event) => {
|
||||||
<div className="min-w-0">
|
event.stopPropagation();
|
||||||
<p className="truncate font-semibold text-slate-100">{extractFileName(text)}</p>
|
opts.onAttachmentContextMenu(event, item.file_url);
|
||||||
<p className="text-[11px] text-slate-400">Audio</p>
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
))}
|
||||||
<AudioInlinePlayer src={text} title={extractFileName(text)} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageType === "file") {
|
if (messageType === "file") {
|
||||||
return (
|
const fileItems = attachments.length ? attachments : (text ? legacyAttachment : []);
|
||||||
<button
|
if (fileItems.length) {
|
||||||
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
|
return (
|
||||||
onContextMenu={(event) => {
|
<div className="space-y-1.5">
|
||||||
event.stopPropagation();
|
{fileItems.map((item, index) => (
|
||||||
opts.onAttachmentContextMenu(event, text);
|
<button
|
||||||
}}
|
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
|
||||||
onClick={() => window.open(text, "_blank", "noopener,noreferrer")}
|
key={`${item.file_url}-${index}`}
|
||||||
type="button"
|
onContextMenu={(event) => {
|
||||||
>
|
event.stopPropagation();
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-400">Document</p>
|
opts.onAttachmentContextMenu(event, item.file_url);
|
||||||
<p className="truncate text-sm font-semibold text-slate-100">{extractFileName(text)}</p>
|
}}
|
||||||
</button>
|
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) }} />;
|
return <p className="whitespace-pre-wrap break-words" dangerouslySetInnerHTML={{ __html: formatMessageHtml(text) }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +1064,46 @@ function canDeleteForEveryone(
|
|||||||
return message.sender_id === meId;
|
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"]);
|
const mediaTypes = new Set(["image", "video", "audio", "voice", "file", "circle_video"]);
|
||||||
if (!mediaTypes.has(message.type)) {
|
if (!mediaTypes.has(message.type)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user