From 71d0a6092c2838787d21b77e3035da38f9361666 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 20:53:52 +0300 Subject: [PATCH] Refine chat reply preview behavior --- backend/communications/serialization.py | 30 +++++++---- .../communications/test_communications_api.py | 29 ++++++++++ frontend/src/app/messages/[chatId]/page.tsx | 18 +++++++ .../components/messages/ChatMessageItem.tsx | 53 +++++++++++++++++-- frontend/src/lib/messages/messageUtils.ts | 44 +++++++++++++-- frontend/src/types/api.ts | 12 ++++- 6 files changed, 165 insertions(+), 21 deletions(-) diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py index 94db40dc..0dac172a 100644 --- a/backend/communications/serialization.py +++ b/backend/communications/serialization.py @@ -86,6 +86,24 @@ def _get_user_avatar_url(user) -> str: return "" +def _serialize_reply_preview(reply_msg) -> dict: + is_deleted = bool(getattr(reply_msg, "is_deleted", False)) + + return { + "id": reply_msg.id, + "content": "" + if is_deleted + else (reply_msg.content[:100] if reply_msg.content else ""), + "author_name": ( + reply_msg.author.get_full_name() + if reply_msg.author + else "Неизвестный" + ), + "is_deleted": is_deleted, + "has_attachments": bool(getattr(reply_msg, "has_attachments", False)), + } + + def _get_message_read_by(m) -> list[dict]: """Список участников, которые дочитали сообщение.""" chat = getattr(m, "chat", None) @@ -281,17 +299,7 @@ def serialize_message(m) -> dict: pk=m.reply_to_id ) - data["reply_to"] = { - "id": reply_msg.id, - "content": ( - reply_msg.content[:100] if reply_msg.content else "" - ), - "author_name": ( - reply_msg.author.get_full_name() - if reply_msg.author - else "Неизвестный" - ), - } + data["reply_to"] = _serialize_reply_preview(reply_msg) except Exception: # Если не удалось загрузить reply_to, просто пропускаем pass diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index 508b1f47..82479d50 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -2451,6 +2451,35 @@ class TestDeleteVariousMessageTypes: assert reply2.is_deleted is False assert reply1.reply_to_id == original.id + def test_messages_around_keeps_deleted_reply_preview_non_clickable_contract( + self, auth_client, private_chat, user1 + ): + """Ответ на удаленное сообщение сохраняет preview, но помечается как deleted.""" + original = Message.objects.create( + chat=private_chat, author=user1, content="Original to delete" + ) + reply = Message.objects.create( + chat=private_chat, author=user1, content="Reply", reply_to=original + ) + + delete_url = f"/api/v1/communications/messages/{original.pk}/" + delete_response = auth_client.delete(delete_url) + + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + url = f"/api/v1/communications/chats/{private_chat.pk}/messages-around/" + response = auth_client.get(url, {"around_id": reply.id}) + + assert response.status_code == status.HTTP_200_OK + serialized_reply = next( + item for item in response.data["messages"] if item["id"] == reply.id + ) + + assert serialized_reply["reply_to"]["id"] == original.id + assert serialized_reply["reply_to"]["is_deleted"] is True + assert serialized_reply["reply_to"]["content"] == "" + assert "author_name" in serialized_reply["reply_to"] + def test_bulk_delete_various_types(self, auth_client, private_chat, user1): """Массовое удаление сообщений разных типов""" from communications.models import MessageAttachment, Poll, PollOption diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index acc0cf4a..c65b899f 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -791,6 +791,21 @@ export default function MessageDialogPage() { } }, [cm, highlightFoundMessage, searchResults]); + const handleJumpToReply = useCallback(async (messageId: number) => { + if (!messageId) { + return; + } + + const jumped = await cm.jumpToMessage(messageId); + if (jumped) { + highlightFoundMessage(messageId); + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + setReactionPickerForMessageId(null); + setIsReadersModalOpen(false); + } + }, [cm, highlightFoundMessage]); + const handleSubmitSelectedSearchResult = useCallback(() => { if (searchResults.length === 0) { return; @@ -1018,6 +1033,9 @@ export default function MessageDialogPage() { brokenMedia={brokenMedia} useOriginalImage={useOriginalImage} onToggleActions={handleToggleMessageActions} + onJumpToReply={(messageId) => { + void handleJumpToReply(messageId); + }} onOpenMediaPreview={setMediaPreview} onAttachmentLoad={handleAttachmentLoad} onAttachmentError={handleAttachmentError} diff --git a/frontend/src/components/messages/ChatMessageItem.tsx b/frontend/src/components/messages/ChatMessageItem.tsx index 756b7131..282988be 100644 --- a/frontend/src/components/messages/ChatMessageItem.tsx +++ b/frontend/src/components/messages/ChatMessageItem.tsx @@ -16,9 +16,13 @@ import { import { resolveMediaUrl } from "@/lib/url"; import { formatFileSize, + getReplyPreview, + getReplyPreviewText, + getReplyToId, getMessageDate, getMessageInitials, getMessagePreviewText, + isReplyPreviewDeleted, isAudioAttachment, isImageAttachment, isVideoAttachment, @@ -55,6 +59,7 @@ type ChatMessageItemProps = { brokenMedia: Record; useOriginalImage: Record; onToggleActions: (messageId: number, anchor: { x: number; y: number }) => void; + onJumpToReply: (messageId: number) => void; onOpenMediaPreview: (preview: MediaPreview) => void; onAttachmentLoad: (attachmentId: number) => void; onAttachmentError: (attachmentId: number) => void; @@ -116,6 +121,7 @@ export default function ChatMessageItem({ brokenMedia, useOriginalImage, onToggleActions, + onJumpToReply, onOpenMediaPreview, onAttachmentLoad, onAttachmentError, @@ -124,7 +130,9 @@ export default function ChatMessageItem({ hasMyReaction, }: ChatMessageItemProps) { const currentDate = getMessageDate(message); - const replyToId = repliedMessage?.id ?? message.reply_to_id ?? (typeof message.reply_to === "number" ? message.reply_to : null); + const replyPreviewMessage = getReplyPreview(message, repliedMessage); + const replyToId = getReplyToId(message); + const canJumpToReply = Boolean(replyToId && !isReplyPreviewDeleted(replyPreviewMessage)); const isMine = Boolean( currentUserId && (message.author_id === currentUserId || message.author?.id === currentUserId || message.sender?.id === currentUserId) @@ -180,10 +188,45 @@ export default function ChatMessageItem({ {!isMine ?

{getMessageAuthorLabel(message)}

: null} {replyToId ? ( -
-

{repliedMessage?.author_name || "Ответ на сообщение"}

-

{repliedMessage ? getMessagePreviewText(repliedMessage) : `Сообщение #${replyToId}`}

-
+ canJumpToReply ? ( + + ) : ( +
+

+ {replyPreviewMessage?.author_name || "Ответ на сообщение"} +

+

+ {replyPreviewMessage ? getReplyPreviewText(replyPreviewMessage) : "Сообщение удалено"} +

+
+ ) ) : null} {message.is_deleted ? ( diff --git a/frontend/src/lib/messages/messageUtils.ts b/frontend/src/lib/messages/messageUtils.ts index be25b56b..d56a886a 100644 --- a/frontend/src/lib/messages/messageUtils.ts +++ b/frontend/src/lib/messages/messageUtils.ts @@ -1,4 +1,12 @@ -import type { Message, MessageAttachment } from "@/types/api"; +import type { Message, MessageAttachment, MessageReplyPreview } from "@/types/api"; + +type ReplyPreviewSource = Message | MessageReplyPreview; + +function isReplyPreviewSource(value: unknown): value is ReplyPreviewSource { + return Boolean( + value && typeof value === "object" && typeof (value as { id?: unknown }).id === "number" + ); +} export function getMessageInitials(message: Message): string { const name = (message.author_name || "").trim(); @@ -89,13 +97,43 @@ export function getMessagePreviewText(message: Message): string { return "[Сообщение]"; } +export function getReplyPreview(message: Message, loadedReply?: Message | null): ReplyPreviewSource | null { + if (loadedReply) { + return loadedReply; + } + + if (isReplyPreviewSource(message.reply_to)) { + return message.reply_to; + } + + if (isReplyPreviewSource(message.reply_to_message)) { + return message.reply_to_message; + } + + return null; +} + +export function isReplyPreviewDeleted(preview: ReplyPreviewSource | null | undefined): boolean { + return Boolean(preview?.is_deleted); +} + +export function getReplyPreviewText(preview: ReplyPreviewSource): string { + if (preview.is_deleted) return "Сообщение удалено"; + if (preview.content?.trim()) return preview.content.trim(); + if ("attachments" in preview && Array.isArray(preview.attachments) && preview.attachments.length > 0) { + return "[Вложение]"; + } + if (preview.has_attachments) return "[Вложение]"; + return "[Сообщение]"; +} + export function getReplyToId(message: Message): number | null { if (typeof message.reply_to_id === "number") return message.reply_to_id; if (typeof message.reply_to === "number") return message.reply_to; - if (message.reply_to && typeof message.reply_to === "object" && typeof message.reply_to.id === "number") { + if (isReplyPreviewSource(message.reply_to)) { return message.reply_to.id; } - if (message.reply_to_message && typeof message.reply_to_message.id === "number") { + if (isReplyPreviewSource(message.reply_to_message)) { return message.reply_to_message.id; } return null; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index f094630b..2ec81864 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -527,8 +527,8 @@ export interface Message { has_attachments?: boolean; attachments?: MessageAttachment[]; reply_to_id?: number; - reply_to?: number | Message | null; - reply_to_message?: Message | null; + reply_to?: number | MessageReplyPreview | Message | null; + reply_to_message?: MessageReplyPreview | Message | null; reactions_summary?: Record; } +export interface MessageReplyPreview { + id: number; + content: string; + author_name?: string; + is_deleted?: boolean; + has_attachments?: boolean; +} + export interface MessageReader { id: number; name: string;