diff --git a/backend/social/chat/consumers.py b/backend/social/chat/consumers.py index 04c27be..077faf0 100644 --- a/backend/social/chat/consumers.py +++ b/backend/social/chat/consumers.py @@ -120,9 +120,11 @@ class ChatConsumer(AsyncWebsocketConsumer): "type": "new_chat_message", "message_id": event["message_id"], "message": event["message"], - "sender_id": event["sender_id"], + "sender_id": event.get("sender_id"), "sender": event["sender"], - "sender_avatar": event["sender_avatar"], + "sender_avatar": event.get("sender_avatar"), + "reply_to_id": event.get("reply_to_id"), + "media_files": event.get("media_files", []), })) async def reply_chat_message(self, event): diff --git a/backend/social/chat/serializers.py b/backend/social/chat/serializers.py index 81cec0d..7349059 100644 --- a/backend/social/chat/serializers.py +++ b/backend/social/chat/serializers.py @@ -4,6 +4,7 @@ from drf_spectacular.utils import extend_schema_field from .models import Chat, ChatReadStatus, Message, MessageFile, MessageHistory, MessageReaction + class MessageSenderSerializer(serializers.ModelSerializer): avatar = serializers.SerializerMethodField() @@ -51,10 +52,42 @@ class ReplyToSerializer(serializers.ModelSerializer): class MessageSerializer(serializers.ModelSerializer): sender = MessageSenderSerializer(read_only=True) - reply_to = ReplyToSerializer(read_only=True) + # reply_to is a SerializerMethodField so we can bypass ActiveManager and + # still surface a tombstone when the original message is soft-deleted. + reply_to = serializers.SerializerMethodField() media_files = MessageFileSerializer(many=True, read_only=True) reactions = MessageReactionSerializer(many=True, read_only=True) + @extend_schema_field(ReplyToSerializer) + def get_reply_to(self, obj): + """ + Fetch the reply-to message via all_objects so soft-deleted originals + are still accessible. Returns content=None when the message is deleted, + which the frontend renders as a '(zpráva smazána)' tombstone. + """ + reply_to_id = obj.reply_to_id + if not reply_to_id: + return None + try: + msg = Message.all_objects.select_related('sender').get(pk=reply_to_id) + except Message.DoesNotExist: + return None + + sender_data = None + if msg.sender: + from django.conf import settings + avatar = (settings.MEDIA_URL + msg.sender.avatar.name) if msg.sender.avatar else None + sender_data = {'id': msg.sender.id, 'username': msg.sender.username, 'avatar': avatar} + else: + sender_data = {'id': 0, 'username': '…', 'avatar': None} + + return { + 'id': msg.id, + # content=None signals the frontend to show the deleted tombstone + 'content': None if msg.is_deleted else msg.content, + 'sender': sender_data, + } + class Meta: model = Message fields = [ diff --git a/backend/social/chat/views.py b/backend/social/chat/views.py index 64fab61..0128422 100644 --- a/backend/social/chat/views.py +++ b/backend/social/chat/views.py @@ -241,12 +241,26 @@ class MessageViewSet(viewsets.ModelViewSet): mt = 'FILE' MessageFile.objects.create(message=message, file=f, media_type=mt) + from django.conf import settings + avatar_url = (settings.MEDIA_URL + request.user.avatar.name) if request.user.avatar else None + media_files_data = [ + { + 'id': f.id, + 'file': settings.MEDIA_URL + f.file.name if f.file else '', + 'media_type': f.media_type, + 'uploaded_at': f.uploaded_at.isoformat(), + } + for f in message.media_files.all() + ] _broadcast(chat.id, { 'type': 'chat.message', 'message_id': message.id, 'message': message.content, + 'sender_id': request.user.id, 'sender': request.user.username, - 'has_files': message.media_files.exists(), + 'sender_avatar': avatar_url, + 'reply_to_id': message.reply_to_id, + 'media_files': media_files_data, }) return Response( diff --git a/frontend/src/api/social/chatSend.ts b/frontend/src/api/social/chatSend.ts new file mode 100644 index 0000000..330ea31 --- /dev/null +++ b/frontend/src/api/social/chatSend.ts @@ -0,0 +1,34 @@ +import { privateMutator } from "@/api/privateClient"; +import type { Message } from "@/api/generated/private/models/message"; + +export interface SendMessageParams { + chatId: number; + content?: string; + replyToId?: number; + files?: File[]; +} + +/** + * Sends a chat message via HTTP multipart POST. + * Use this whenever files are attached; text-only messages can go through WS. + */ +export async function sendChatMessage({ + chatId, + content, + replyToId, + files, +}: SendMessageParams): Promise { + const form = new FormData(); + form.append("chat", String(chatId)); + if (content) form.append("content", content); + if (replyToId != null) form.append("reply_to", String(replyToId)); + for (const file of files ?? []) { + form.append("files", file); + } + // privateMutator deletes Content-Type for FormData so Axios sets the multipart boundary + return privateMutator({ + url: "/api/social/messages/send/", + method: "POST", + data: form, + }); +} diff --git a/frontend/src/components/social/chat/ChatMediaGallery.tsx b/frontend/src/components/social/chat/ChatMediaGallery.tsx new file mode 100644 index 0000000..86a5e0f --- /dev/null +++ b/frontend/src/components/social/chat/ChatMediaGallery.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { FiX, FiChevronLeft, FiChevronRight, FiFile, FiDownload } from "react-icons/fi"; +import type { MessageFile } from "@/api/generated/private/models/messageFile"; +import { mediaUrl } from "@/utils/mediaUrl"; + +interface Props { + files: readonly MessageFile[]; +} + +export default function ChatMediaGallery({ files }: Props) { + const [lightboxIndex, setLightboxIndex] = useState(null); + const visible = files.slice(0, 4); + const overflow = files.length - visible.length; + const isOpen = lightboxIndex !== null; + + const prev = useCallback( + () => setLightboxIndex((i) => (i !== null ? (i - 1 + files.length) % files.length : null)), + [files.length], + ); + const next = useCallback( + () => setLightboxIndex((i) => (i !== null ? (i + 1) % files.length : null)), + [files.length], + ); + const close = useCallback(() => setLightboxIndex(null), []); + + useEffect(() => { + if (!isOpen) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") close(); + if (e.key === "ArrowLeft") prev(); + if (e.key === "ArrowRight") next(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, close, prev, next]); + + if (!files?.length) return null; + + const layoutClass = visible.length === 1 ? "grid-cols-1" : "grid-cols-2"; + + return ( + <> +
+ {visible.map((file, i) => ( + 0 ? overflow : 0} + onOpen={() => setLightboxIndex(i)} + /> + ))} +
+ + {isOpen && + createPortal( +
+ + + {files.length > 1 && ( + + )} + + + + {files.length > 1 && ( + + )} + + {files.length > 1 && ( +
e.stopPropagation()} + > + {files.map((_, i) => ( +
+ )} +
, + document.body, + )} + + ); +} + +function ChatLightboxContent({ file }: { file: MessageFile }) { + const url = mediaUrl(file.file) ?? ""; + const type = file.media_type ?? "FILE"; + + if (type === "VIDEO") { + return ( +