Refine chat reply preview behavior
This commit is contained in:
parent
9b23f480c2
commit
71d0a6092c
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user