fix(messages): improve chat previews and creation modal

This commit is contained in:
lizerk 2026-08-06 15:19:27 +03:00
parent 418a778942
commit 0d252753a3
9 changed files with 288 additions and 87 deletions

View File

@ -50,15 +50,59 @@ def _get_last_message(obj):
return (
obj.messages.filter(is_deleted=False)
.select_related("author")
.select_related("author", "poll")
.prefetch_related("attachments")
.order_by("-created_at")
.first()
)
def _get_last_message_preview(message):
content = (message.content or "").strip()
if content:
return content[:100], "text"
poll = getattr(message, "poll", None)
if poll is not None:
question = (poll.question or "").strip()
preview = f"Голосование: {question}" if question else "Голосование"
return preview[:100], "poll"
attachments = list(message.attachments.all())
if len(attachments) > 1:
return f"Вложения: {len(attachments)}", "attachments"
if attachments:
attachment_type = attachments[0].file_type
labels = {
"image": "Фото",
"video": "Видео",
"audio": "Аудио",
"file": "Файл",
}
return labels.get(attachment_type, "Файл"), attachment_type or "file"
return "Сообщение", "message"
def _serialize_last_message(message):
preview_text, preview_type = _get_last_message_preview(message)
return {
"id": message.id,
"content": (message.content or "")[:100],
"preview_text": preview_text,
"preview_type": preview_type,
"author_name": (
message.author.get_full_name() if message.author else "Unknown"
),
"created_at": message.created_at.isoformat(),
}
class ChatLastMessageSerializer(serializers.Serializer):
id = serializers.IntegerField()
content = serializers.CharField()
content = serializers.CharField(allow_blank=True)
preview_text = serializers.CharField()
preview_type = serializers.CharField()
author_name = serializers.CharField()
created_at = serializers.DateTimeField()
@ -173,14 +217,7 @@ class ChatListSerializer(serializers.ModelSerializer):
"""Последнее сообщение в чате (использует prefetch если доступен)"""
last_msg = _get_last_message(obj)
if last_msg:
return {
"id": last_msg.id,
"content": last_msg.content[:100],
"author_name": last_msg.author.get_full_name()
if last_msg.author
else "Unknown",
"created_at": last_msg.created_at.isoformat(),
}
return _serialize_last_message(last_msg)
return None
@extend_schema_field(ChatParticipantPreviewSerializer(many=True))
@ -367,14 +404,7 @@ class ChatDetailSerializer(serializers.ModelSerializer):
if not last_msg:
return None
return {
"id": last_msg.id,
"content": last_msg.content[:100],
"author_name": last_msg.author.get_full_name()
if last_msg.author
else "Unknown",
"created_at": last_msg.created_at.isoformat(),
}
return _serialize_last_message(last_msg)
@extend_schema_field(serializers.IntegerField())
def get_unread_count(self, obj):

View File

@ -230,7 +230,9 @@ class ChatViewSet(viewsets.ModelViewSet):
'messages',
queryset=Message.objects.filter(
is_deleted=False,
).select_related('author').order_by('-created_at')[:1],
).select_related('author', 'poll').prefetch_related(
'attachments'
).order_by('-created_at')[:1],
to_attr='_prefetched_last_message'
),
).distinct()

View File

@ -3,7 +3,15 @@
"""
import pytest
from communications.models import Chat, ChatMembership, ChatReadState, ChatUserSettings, Message
from communications.models import (
Chat,
ChatMembership,
ChatReadState,
ChatUserSettings,
Message,
MessageAttachment,
Poll,
)
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
@ -115,6 +123,60 @@ class TestChatViewSet:
assert response.status_code == status.HTTP_200_OK
assert len(response.data) >= 2
def test_last_message_preview_for_attachment_and_poll(
self,
auth_client,
private_chat,
user1,
):
image_message = Message.objects.create(
chat=private_chat,
author=user1,
content="",
has_attachments=True,
)
MessageAttachment.objects.bulk_create([
MessageAttachment(
message=image_message,
file="chat_attachments/test.jpg",
file_type="image",
file_name="test.jpg",
file_size=128,
mime_type="image/jpeg",
)
])
response = auth_client.get("/api/v1/communications/chats/")
assert response.status_code == status.HTTP_200_OK
chat_data = next(
item
for item in response.data["results"]
if item["id"] == private_chat.id
)
assert chat_data["last_message"]["preview_text"] == "Фото"
assert chat_data["last_message"]["preview_type"] == "image"
poll_message = Message.objects.create(
chat=private_chat,
author=user1,
content="",
)
Poll.objects.create(
message=poll_message,
author=user1,
question="Куда пойдем?",
)
response = auth_client.get("/api/v1/communications/chats/")
assert response.status_code == status.HTTP_200_OK
chat_data = next(
item
for item in response.data["results"]
if item["id"] == private_chat.id
)
assert chat_data["last_message"]["preview_text"] == "Голосование: Куда пойдем?"
assert chat_data["last_message"]["preview_type"] == "poll"
def test_list_chats_unauthenticated(self):
"""Список чатов без аутентификации - должен вернуть 403"""
client = APIClient()

View File

@ -326,7 +326,9 @@ export default function ChatscapeMessenger() {
{filteredChats.map(chat => {
const title = getChatTitle(chat, currentUserId);
const avatar = getChatAvatar(chat, currentUserId);
const lastMessage = chat.last_message?.content || 'Нет сообщений';
const lastMessage = chat.last_message?.preview_text
|| chat.last_message?.content
|| 'Нет сообщений';
const unreadCount = chat.unread_count || 0;
return (

View File

@ -3,7 +3,7 @@
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState, type ChangeEvent } from "react";
import { Globe, Image as ImageIcon, Radio, Users, X } from "lucide-react";
import { Globe, ImagePlus, Radio, Trash2, Users } from "lucide-react";
import { apiClient } from "@/lib/api";
import { Modal } from "@/components/ui";
@ -20,6 +20,27 @@ type CreateChatPayload = {
avatar?: File;
};
const chatTypes = [
{
value: "group",
label: "Групповой",
description: "Общение участников",
icon: Users,
},
{
value: "channel",
label: "Канал",
description: "Публикации для подписчиков",
icon: Radio,
},
{
value: "global",
label: "Глобальный",
description: "Доступен всей компании",
icon: Globe,
},
] as const;
export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProps) {
const router = useRouter();
const [chatType, setChatType] = useState<"group" | "channel" | "global">("group");
@ -28,6 +49,7 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
const [avatar, setAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const resetForm = () => {
setChatType("group");
@ -35,20 +57,23 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
setDescription("");
setAvatar(null);
setAvatarPreview(null);
setError(null);
};
const close = () => {
if (creating) return;
resetForm();
onClose();
};
const handleCreate = async () => {
if (creating) return;
if (!name.trim()) {
alert("Введите название чата");
setError("Введите название чата");
return;
}
setError(null);
setCreating(true);
try {
const payload: CreateChatPayload = {
@ -65,7 +90,7 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
} catch (error: unknown) {
console.error("Ошибка создания чата:", error);
const message = error instanceof Error ? error.message : String(error);
alert(`Не удалось создать чат: ${message}`);
setError(`Не удалось создать чат: ${message}`);
} finally {
setCreating(false);
}
@ -75,10 +100,12 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
alert("Можно загружать только изображения");
setError("Для аватара можно загрузить только изображение");
event.target.value = "";
return;
}
setError(null);
setAvatar(file);
const reader = new FileReader();
reader.onloadend = () => setAvatarPreview(reader.result as string);
@ -90,87 +117,141 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
isOpen={isOpen}
onClose={close}
title="Создать чат"
size="sm"
size="md"
closeOnEsc={!creating}
className="h-[100dvh] max-w-full rounded-none sm:h-auto sm:rounded-2xl"
footer={(
<div className="flex gap-2">
<button type="button" onClick={close} className="app-action-secondary flex-1 rounded-lg px-4 py-2 text-sm font-medium transition">
<div className="flex flex-wrap items-center justify-end gap-2">
<button type="button" onClick={close} className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium transition">
Отмена
</button>
<button type="button" onClick={handleCreate} disabled={creating} className="app-action-primary flex-1 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50">
<button
type="submit"
form="create-chat-form"
disabled={creating || !name.trim()}
className="app-action-primary rounded-lg px-5 py-2.5 text-sm font-medium transition disabled:opacity-50"
>
{creating ? "Создание..." : "Создать"}
</button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">Тип чата</label>
<div className="flex gap-2">
{([
["group", "Групповой", Users],
["channel", "Канал", Radio],
["global", "Глобальный", Globe],
] as const).map(([value, label, Icon]) => (
<button
key={value}
type="button"
onClick={() => setChatType(value)}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg p-3 transition ${chatType === value ? "app-selected" : "app-action-secondary"}`}
>
<Icon size={18} />
<span className="text-sm font-medium">{label}</span>
</button>
))}
<form
id="create-chat-form"
className="space-y-4 pb-2"
onSubmit={(event) => {
event.preventDefault();
void handleCreate();
}}
>
{error && (
<p className="app-feedback-danger rounded-xl px-4 py-3 text-sm" role="alert">
{error}
</p>
)}
<section className="app-surface-muted rounded-xl p-3 sm:p-4">
<div className="space-y-1.5">
<span className="app-text-muted block text-xs font-medium">Тип чата</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{chatTypes.map(({ value, label, description: typeDescription, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setChatType(value)}
aria-pressed={chatType === value}
className={`flex min-h-[4.25rem] min-w-0 items-center gap-3 rounded-lg border px-3 py-3 text-left transition ${
chatType === value
? "app-selected border-[var(--accent-primary)]"
: "border-[var(--border-subtle)] bg-[var(--surface-primary)] hover:border-[var(--border-strong)]"
}`}
>
<span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${chatType === value ? "bg-[var(--accent-primary)] text-white" : "app-surface-muted app-text-muted"}`}>
<Icon size={16} />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-[var(--foreground)]">{label}</span>
<span className="app-text-muted line-clamp-2 block text-[11px] leading-4">{typeDescription}</span>
</span>
</button>
))}
</div>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">Название *</label>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Введите название чата..." className="app-input w-full rounded-lg px-3 py-2 text-sm" />
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="space-y-1.5">
<span className="app-text-muted block text-xs font-medium">
Название <span className="app-accent-text">*</span>
</span>
<input
value={name}
onChange={(event) => {
setName(event.target.value);
if (error) setError(null);
}}
placeholder="Введите название чата"
maxLength={100}
autoFocus
className="app-input w-full rounded-lg px-4 py-3 text-sm"
/>
</label>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">Описание (необязательно)</label>
<textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Краткое описание чата..." rows={3} className="app-input w-full rounded-lg px-3 py-2 text-sm" />
</div>
<label className="space-y-1.5">
<span className="app-text-muted block text-xs font-medium">Описание</span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Краткое описание чата"
rows={3}
maxLength={500}
className="app-input min-h-[7.25rem] w-full resize-y rounded-lg px-4 py-3 text-sm sm:min-h-0"
/>
</label>
</div>
</section>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">Аватар (необязательно)</label>
{avatarPreview ? (
<div className="flex items-center gap-3">
<Image src={avatarPreview} alt="Предпросмотр аватара" width={80} height={80} unoptimized className="h-20 w-20 rounded-full object-cover" />
<div className="min-w-0 flex-1">
<p className="app-text-muted mb-2 truncate text-sm">{avatar?.name}</p>
<section className="app-surface-muted rounded-xl p-3 sm:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="app-avatar-fallback flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-full">
{avatarPreview ? (
<Image src={avatarPreview} alt="Предпросмотр аватара" width={64} height={64} unoptimized className="h-full w-full object-cover" />
) : (
<ImagePlus size={22} className="app-text-muted" />
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--foreground)]">Аватар чата</p>
<p className="app-text-muted mt-1 truncate text-xs">
{avatar?.name || "Изображение можно добавить позже"}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<label className="app-action-secondary inline-flex cursor-pointer items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-medium">
<input type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
<ImagePlus size={15} />
{avatarPreview ? "Заменить" : "Загрузить"}
</label>
{avatarPreview && (
<button
type="button"
onClick={() => {
setAvatar(null);
setAvatarPreview(null);
}}
className="app-action-danger inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm font-medium transition"
className="app-icon-button app-action-danger rounded-lg p-2.5"
title="Удалить аватар"
aria-label="Удалить аватар"
>
<X size={14} />
Удалить
<Trash2 size={15} />
</button>
</div>
)}
</div>
) : (
<label className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-[var(--border-strong)] bg-[var(--surface-secondary)] p-6 transition hover:border-[color:color-mix(in_srgb,var(--accent-primary)_30%,var(--border-strong))] hover:bg-[var(--accent-soft)]">
<input type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
<div className="flex flex-col items-center gap-2">
<div className="rounded-full bg-[var(--accent-soft)] p-3">
<ImageIcon size={24} className="app-accent-text" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-[var(--foreground)]">Загрузить изображение</p>
<p className="app-text-muted mt-1 text-xs">Изображение для аватара чата</p>
</div>
</div>
</label>
)}
</div>
</div>
</div>
</section>
</form>
</Modal>
);
}

View File

@ -24,6 +24,7 @@ import {
type ChatIdentity,
getChatAvatar,
getChatInitials,
getChatLastMessagePreview,
getChatTitle,
isDepartmentCommentsChat,
} from "@/lib/messages/chatUtils";
@ -221,7 +222,7 @@ export default function MessagesChatListItem({
<span className="app-text-muted shrink-0 text-xs">{formatTime(chat.last_message?.created_at)}</span>
</div>
<p className={`mt-1 truncate text-xs ${unread > 0 ? "font-medium text-[var(--foreground)]" : "app-text-muted"}`}>
{chat.last_message?.content || "Нет сообщений"}
{getChatLastMessagePreview(chat)}
</p>
</div>
</div>

View File

@ -5,6 +5,7 @@ import { useMemo } from "react";
import { useUser } from "@/contexts/UserContext";
import {
type ChatIdentity,
getChatLastMessagePreview,
getChatTitle,
isDepartmentCommentsChat,
} from "@/lib/messages/chatUtils";
@ -66,7 +67,10 @@ export function useMessagesDirectory() {
if (!query) return true;
const title = getChatTitle(chat, user?.id, identity).toLowerCase();
return title.includes(query) || (chat.last_message?.content || "").toLowerCase().includes(query);
const lastMessagePreview = chat.last_message
? getChatLastMessagePreview(chat).toLowerCase()
: "";
return title.includes(query) || lastMessagePreview.includes(query);
});
}, [chatTypeFilter, chats, filterPinned, filterUnread, identity, search, user?.id]);

View File

@ -94,6 +94,14 @@ export function isDepartmentCommentsChat(chat: Chat): boolean {
return chatKind === "comments" && Boolean(chat.flags?.show_in_messages);
}
export function getChatLastMessagePreview(chat: Chat): string {
const lastMessage = chat.last_message;
if (!lastMessage) return "Нет сообщений";
return lastMessage.preview_text?.trim()
|| lastMessage.content?.trim()
|| "Сообщение";
}
export function getChatAvatar(chat: Chat, currentUserId?: number): string {
const chatKind = chat.chat_type || chat.type;
if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") {

View File

@ -1460,6 +1460,17 @@ export interface ProcurementDepartmentStats {
}
// Communications types
export interface ChatLastMessage {
id: number;
content: string;
preview_text?: string;
preview_type?: 'text' | 'image' | 'video' | 'audio' | 'file' | 'attachments' | 'poll' | 'message';
author_name?: string;
created_at: string;
created_ts?: number;
created?: string;
}
export interface Chat {
id: number;
name?: string;
@ -1471,7 +1482,7 @@ export interface Chat {
participants?: Array<User | number>;
participant_names?: string[];
participant_details?: Array<{ id: number; name?: string; avatar?: string | null }>;
last_message?: Message;
last_message?: ChatLastMessage | null;
unread_count?: number;
created_at: string;
created_by?: number;