Refine chat reply preview behavior

This commit is contained in:
lizerk 2026-04-03 20:53:52 +03:00
parent 9b23f480c2
commit 71d0a6092c
6 changed files with 165 additions and 21 deletions

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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<number, boolean>;
useOriginalImage: Record<number, boolean>;
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 ? <p className="mb-1 text-[11px] font-medium text-gray-500">{getMessageAuthorLabel(message)}</p> : null}
{replyToId ? (
<div className={`mb-2 rounded-lg border-l-2 px-2 py-1 text-xs ${isMine ? "border-sky-200 bg-sky-400/30 text-sky-50" : "border-gray-300 bg-gray-100 text-gray-600"}`}>
<p className="font-medium">{repliedMessage?.author_name || "Ответ на сообщение"}</p>
<p className="truncate">{repliedMessage ? getMessagePreviewText(repliedMessage) : `Сообщение #${replyToId}`}</p>
</div>
canJumpToReply ? (
<button
type="button"
onClick={() => onJumpToReply(replyToId)}
className={[
"group mb-2 block w-full rounded-xl border-l-2 px-2.5 py-1.5 text-left text-xs transition focus-visible:outline-none",
isMine
? "border-sky-200 bg-sky-400/30 text-sky-50 hover:bg-sky-400/40 focus-visible:bg-sky-400/40 focus-visible:ring-2 focus-visible:ring-white/35"
: "border-gray-300 bg-gray-100 text-gray-600 hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:ring-2 focus-visible:ring-sky-200",
].join(" ")}
title="Перейти к исходному сообщению"
aria-label={`Перейти к сообщению ${replyPreviewMessage?.author_name || `#${replyToId}`}`}
>
<p className="font-medium opacity-90 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
{replyPreviewMessage?.author_name || "Ответ на сообщение"}
</p>
<p className="truncate opacity-90 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
{replyPreviewMessage ? getReplyPreviewText(replyPreviewMessage) : `Сообщение #${replyToId}`}
</p>
</button>
) : (
<div
className={[
"mb-2 block w-full rounded-xl border-l-2 px-2.5 py-1.5 text-left text-xs opacity-85",
isMine
? "border-sky-200/80 bg-sky-400/20 text-sky-50/95"
: "border-gray-300 bg-gray-100 text-gray-500",
].join(" ")}
title="Исходное сообщение удалено"
aria-label={`Исходное сообщение удалено${replyPreviewMessage?.author_name ? `, автор ${replyPreviewMessage.author_name}` : ""}`}
>
<p className="font-medium">
{replyPreviewMessage?.author_name || "Ответ на сообщение"}
</p>
<p className="truncate">
{replyPreviewMessage ? getReplyPreviewText(replyPreviewMessage) : "Сообщение удалено"}
</p>
</div>
)
) : null}
{message.is_deleted ? (

View File

@ -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;

View File

@ -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<string, {
count: number;
users?: number[];
@ -536,6 +536,14 @@ export interface Message {
}>;
}
export interface MessageReplyPreview {
id: number;
content: string;
author_name?: string;
is_deleted?: boolean;
has_attachments?: boolean;
}
export interface MessageReader {
id: number;
name: string;