From 38ab1afa7c4365676f8e37994555bc521909be1d Mon Sep 17 00:00:00 2001 From: Konstantin Makaev Date: Tue, 24 Feb 2026 13:11:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=BB=D1=8C=D1=84=D0=B0=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/v1/communications/serializers.py | 32 +- backend/api/v1/communications/views.py | 29 +- backend/api/v1/feed/serializers.py | 7 +- backend/api/v1/feed/views.py | 94 +- frontend/next.config.ts | 5 + frontend/src/app/api/[...path]/route.ts | 32 +- frontend/src/app/departments/page.tsx | 96 +- frontend/src/app/documents/page.tsx | 140 ++- frontend/src/app/employees/page.tsx | 83 +- frontend/src/app/messages/[chatId]/page.tsx | 1163 ++++++++++++++++-- frontend/src/app/messages/page.tsx | 219 +++- frontend/src/app/page.tsx | 850 ++++++++++++- frontend/src/app/profile/page.tsx | 429 +++++++ frontend/src/app/requests/page.tsx | 152 ++- frontend/src/app/search/page.tsx | 219 ++++ frontend/src/components/AppShell.tsx | 713 +++++++++-- frontend/src/types/api.ts | 34 +- 17 files changed, 3947 insertions(+), 350 deletions(-) create mode 100644 frontend/src/app/profile/page.tsx create mode 100644 frontend/src/app/search/page.tsx diff --git a/backend/api/v1/communications/serializers.py b/backend/api/v1/communications/serializers.py index 9740b27e..64f975d3 100644 --- a/backend/api/v1/communications/serializers.py +++ b/backend/api/v1/communications/serializers.py @@ -220,7 +220,7 @@ class PollSerializer(serializers.ModelSerializer): def create(self, validated_data): """Создание голосования с опциями""" - from communications.models import Chat, Message + from communications.models import Chat, Message, PollOption from datetime import timedelta from django.utils import timezone import logging @@ -237,7 +237,11 @@ class PollSerializer(serializers.ModelSerializer): logger.info(f"[PollSerializer.create] options_data: {options_data}, chat_id: {chat_id}") # Получаем автора (может быть передан через perform_create или взять из запроса) - author = validated_data.pop('author', None) or self.context.get('request').user + request = self.context.get('request') + request_user = getattr(request, 'user', None) + author = validated_data.pop('author', None) or request_user + if author is None: + raise serializers.ValidationError({'author': 'Author is required'}) # Создаем сообщение в чате try: @@ -267,7 +271,8 @@ class PollSerializer(serializers.ModelSerializer): # Создаем опции for index, option_text in enumerate(options_data): is_correct = (correct_option_index is not None and index == correct_option_index) - poll.options.create( + PollOption.objects.create( + poll=poll, text=option_text, position=index, is_correct=is_correct @@ -283,16 +288,19 @@ class PollSerializer(serializers.ModelSerializer): message_data = serialize_message(message) channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - f'chat_{chat.id}', - { - 'type': 'chat_message', - 'chat_id': chat.id, - 'payload': message_data - } - ) + chat_id_value = getattr(chat, 'id', chat_id) + if channel_layer is not None: + async_to_sync(channel_layer.group_send)( + f'chat_{chat_id_value}', + { + 'type': 'chat_message', + 'chat_id': chat_id_value, + 'payload': message_data + } + ) - logger.info(f"[PollSerializer.create] WebSocket notification sent for message {message.id}") + message_id_value = getattr(message, 'id', None) + logger.info(f"[PollSerializer.create] WebSocket notification sent for message {message_id_value}") return poll diff --git a/backend/api/v1/communications/views.py b/backend/api/v1/communications/views.py index a1e5627e..0e652408 100644 --- a/backend/api/v1/communications/views.py +++ b/backend/api/v1/communications/views.py @@ -13,7 +13,10 @@ Endpoints: - Миграция с FBV на ViewSets завершена: 14 января 2026 - Удаление legacy кода: 15 января 2026 """ +# pyright: reportAttributeAccessIssue=false, reportCallIssue=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false + import logging +from typing import Any, cast from datetime import datetime, timezone as dt_tz from django.contrib.auth import get_user_model from django.db import transaction @@ -64,13 +67,16 @@ class ChatViewSet(viewsets.ModelViewSet): """ permission_classes = [IsAuthenticated] + # Явно задаем базовые атрибуты для корректной типизации DRF/Pylance + queryset = Chat.objects.none() + serializer_class = ChatDetailSerializer - def get_serializer_class(self): + def get_serializer_class(self): # pyright: ignore[reportIncompatibleMethodOverride] if self.action == 'list': return ChatListSerializer return ChatDetailSerializer - def get_queryset(self): + def get_queryset(self): # pyright: ignore[reportIncompatibleMethodOverride] """Чаты доступные пользователю с аннотацией непрочитанных""" user = self.request.user @@ -454,8 +460,11 @@ class MessageViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser, JSONParser] + # Явно задаем базовые атрибуты для корректной типизации DRF/Pylance + queryset = Message.objects.none() + serializer_class = MessageDetailSerializer - def get_serializer_class(self): + def get_serializer_class(self): # pyright: ignore[reportIncompatibleMethodOverride] if self.action in ['create', 'upload']: return MessageCreateSerializer elif self.action == 'update' or self.action == 'partial_update': @@ -464,7 +473,7 @@ class MessageViewSet(viewsets.ModelViewSet): return MessageListSerializer return MessageDetailSerializer - def get_queryset(self): + def get_queryset(self): # pyright: ignore[reportIncompatibleMethodOverride] """Сообщения доступные пользователю""" user = self.request.user @@ -879,9 +888,10 @@ class MessageViewSet(viewsets.ModelViewSet): """Переслать сообщения в другой чат""" serializer = ForwardMessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) - - message_ids = serializer.validated_data['message_ids'] - target_chat_id = serializer.validated_data['target_chat_id'] + + validated = cast(dict[str, Any], serializer.validated_data) + message_ids = validated['message_ids'] + target_chat_id = validated['target_chat_id'] # Получаем целевой чат try: @@ -940,8 +950,9 @@ class MessageViewSet(viewsets.ModelViewSet): """Массовое удаление сообщений""" serializer = BulkDeleteSerializer(data=request.data) serializer.is_valid(raise_exception=True) - - message_ids = serializer.validated_data['message_ids'] + + validated = cast(dict[str, Any], serializer.validated_data) + message_ids = validated['message_ids'] # Удаляем только свои сообщения deleted_count = Message.objects.filter( diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py index 983d6f35..8b39dc5d 100644 --- a/backend/api/v1/feed/serializers.py +++ b/backend/api/v1/feed/serializers.py @@ -120,7 +120,12 @@ class PostListSerializer(serializers.ModelSerializer): class PostSerializer(PostListSerializer): - """Сериализатор для деталей поста с комментариями""" + """Сериализатор для деталей поста с комментариями. + + image/attachment наследуются от PostListSerializer как SerializerMethodField (read-only). + Запись файлов происходит вручную во view через request.FILES + (PostViewSet._attach_files), по аналогии с upload в communications. + """ comments = serializers.SerializerMethodField() user_has_liked = serializers.SerializerMethodField() diff --git a/backend/api/v1/feed/views.py b/backend/api/v1/feed/views.py index 97fe52ac..359db728 100644 --- a/backend/api/v1/feed/views.py +++ b/backend/api/v1/feed/views.py @@ -22,7 +22,12 @@ from ..permissions import ( user_is_dept_head, user_is_staffish, ) -from .serializers import CommentSerializer, PostListSerializer, PostSerializer +from .serializers import ( + AuthorMiniSerializer, + CommentSerializer, + PostListSerializer, + PostSerializer, +) Employee = get_user_model() @@ -59,6 +64,7 @@ class PostViewSet(viewsets.ModelViewSet): .annotate(comments_count=Count("comments", distinct=True)) .order_by("-pinned", "-created_at") ) + serializer_class = PostSerializer parser_classes = [MultiPartParser, FormParser, JSONParser] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ["title", "body"] @@ -98,7 +104,9 @@ class PostViewSet(viewsets.ModelViewSet): # --- CREATE: смотрим на тип из тела запроса --- if self.action == "create": - t = (self.request.data.get("type") or "").strip() + req_data = getattr(self.request, "data", {}) + raw_type = req_data.get("type") if hasattr(req_data, "get") else "" + t = (raw_type or "").strip() if t == TYPE_COMPANY: return [IsAuthenticated(), AdminOrActionOrModelPerms()] # department → департаментное право на создание @@ -132,9 +140,6 @@ class PostViewSet(viewsets.ModelViewSet): # дефолт: просто аутентификация return [IsAuthenticated()] - def get_serializer_class(self): - return PostListSerializer if self.action == "list" else PostSerializer - def get_queryset(self): qs = super().get_queryset() user = self.request.user @@ -181,7 +186,7 @@ class PostViewSet(viewsets.ModelViewSet): if ids: last_comments_map = Comment.objects.select_related("author").in_bulk(ids) - serializer = self.get_serializer( + serializer = PostListSerializer( objects, many=True, context={ @@ -193,14 +198,42 @@ class PostViewSet(viewsets.ModelViewSet): return self.get_paginated_response(serializer.data) return Response(serializer.data) - # --- ВАЖНО: возвращаем perform_create, чтобы заполнить author --- - def perform_create(self, serializer: PostSerializer) -> None: - """Сохраняет пост, проставляя автора текущим пользователем. + # --- Файлы из request.FILES сохраняются вручную --- + # Аналогично механизму upload в communications (MessageViewSet.upload), + # где файлы берутся напрямую из request.FILES, а не через сериализатор. - Raises: - ValidationError: если сериализатор невалиден (обрабатывается DRF). + def perform_create(self, serializer: PostSerializer) -> None: + """Сохраняет пост, проставляя автора. + Файлы (image/attachment) записываются через _attach_files. """ - serializer.save(author=self.request.user) + instance = serializer.save(author=self.request.user) + self._attach_files(instance) + + def perform_update(self, serializer) -> None: + """Обновляет пост. Файлы записываются через _attach_files.""" + instance = serializer.save() + self._attach_files(instance) + + def _attach_files(self, instance: Post) -> None: + """Сохраняет файлы из request.FILES в инстанс поста. + + По аналогии с communications.views.MessageViewSet.upload: + файлы берутся напрямую из request.FILES, минуя сериализатор. + """ + files = self.request.FILES + image = files.get("image") + attachment = files.get("attachment") + + update_fields = [] + if image: + instance.image = image # type: ignore[assignment] + update_fields.append("image") + if attachment: + instance.attachment = attachment # type: ignore[assignment] + update_fields.append("attachment") + + if update_fields: + instance.save(update_fields=update_fields) # --- actions (pin/unpin и лайки) --- @@ -247,6 +280,32 @@ class PostViewSet(viewsets.ModelViewSet): status=status.HTTP_200_OK, ) + @action( + detail=True, + methods=["get"], + permission_classes=[IsAuthenticated], + url_path="likes-users", + ) + def likes_users(self, request, pk=None): + """Возвращает список пользователей, поставивших лайк посту.""" + post = self.get_object() + + likes_qs = ( + PostLike.objects.filter(post=post) + .select_related("user") + .order_by("-created_at")[:50] + ) + users = [like.user for like in likes_qs] + + serializer = AuthorMiniSerializer(users, many=True, context={"request": request}) + return Response( + { + "count": post.likes_count, + "results": serializer.data, + }, + status=status.HTTP_200_OK, + ) + class CommentViewSet(viewsets.ModelViewSet): """ @@ -271,6 +330,17 @@ class CommentViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): serializer.save(author=self.request.user) + def perform_update(self, serializer): + """Редактирование только своих комментариев. + + Staff/superuser могут удалять чужие комментарии, но не редактировать. + """ + instance = serializer.instance + user_pk = getattr(self.request.user, "pk", None) + if not instance or instance.author_id != user_pk: + raise PermissionDenied("Редактировать можно только свои комментарии.") + serializer.save() + def get_queryset(self): qs = super().get_queryset() qp = self.request.query_params diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 0f3f4a66..c7987de8 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,6 +1,11 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // Указываем корень проекта для Turbopack, чтобы не путался с lockfile в корне монорепо + turbopack: { + root: __dirname, + }, + // Отключаем автоматический редирект trailing slash // Django требует слэш в конце URL, а Next.js по умолчанию его убирает skipTrailingSlashRedirect: true, diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 42a42071..7f33cf66 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -50,10 +50,13 @@ async function proxyRequest(request: NextRequest, method: string) { } }); - // Получаем тело запроса если есть - const body = method !== 'GET' && method !== 'HEAD' - ? await request.text() - : undefined; + // Получаем тело запроса как бинарные данные. + // ВАЖНО: text() ломает бинарные файлы (png/mp4) при multipart/form-data. + let body: BodyInit | undefined = undefined; + if (method !== 'GET' && method !== 'HEAD') { + const rawBody = await request.arrayBuffer(); + body = rawBody.byteLength > 0 ? rawBody : undefined; + } // Делаем запрос к backend const response = await fetch(fullUrl, { @@ -68,6 +71,27 @@ async function proxyRequest(request: NextRequest, method: string) { responseHeaders.set(key, value); }); + // Для 204/205/304 тело ответа запрещено по HTTP-спецификации. + // Иначе NextResponse выбрасывает: "Invalid response status code 204". + const noBodyStatuses = new Set([204, 205, 304]); + if (noBodyStatuses.has(response.status)) { + responseHeaders.delete('content-length'); + responseHeaders.delete('content-type'); + + return new NextResponse(null, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } + + // response.text() уже декодирует gzip/br/deflate. + // Удаляем encoding-заголовки, иначе браузер попытается + // распаковать повторно → ERR_CONTENT_DECODING_FAILED. + responseHeaders.delete('content-encoding'); + responseHeaders.delete('content-length'); + responseHeaders.delete('transfer-encoding'); + // Возвращаем ответ от backend const responseBody = await response.text(); diff --git a/frontend/src/app/departments/page.tsx b/frontend/src/app/departments/page.tsx index e98ae9f3..0bf12cd3 100644 --- a/frontend/src/app/departments/page.tsx +++ b/frontend/src/app/departments/page.tsx @@ -1,9 +1,103 @@ +"use client"; + import { AppShell } from "../../components/AppShell"; +import { apiClient } from "@/lib/api"; +import { useEffect, useMemo, useState } from "react"; +import type { Department } from "@/types/api"; +import { Building2, Search, Users } from "lucide-react"; export default function DepartmentsPage() { + const [departments, setDepartments] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadDepartments() { + try { + setLoading(true); + setError(null); + const response = await apiClient.getDepartments(); + setDepartments(response.results || []); + } catch (err) { + console.error("Ошибка загрузки отделов:", err); + setError("Не удалось загрузить отделы"); + } finally { + setLoading(false); + } + } + + loadDepartments(); + }, []); + + const filteredDepartments = useMemo(() => { + const q = search.trim().toLowerCase(); + const sorted = [...departments].sort((a, b) => a.name.localeCompare(b.name, "ru")); + if (!q) return sorted; + + return sorted.filter((dep) => { + const name = dep.name.toLowerCase(); + const desc = (dep.description || "").toLowerCase(); + const head = dep.head ? `${dep.head.last_name} ${dep.head.first_name}`.toLowerCase() : ""; + return name.includes(q) || desc.includes(q) || head.includes(q); + }); + }, [departments, search]); + return ( -

Раздел в разработке.

+ {loading ? ( +
+
+

Загрузка отделов...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+
+ + setSearch(e.target.value)} + placeholder="Поиск по отделам" + className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" + /> +
+ +
+ {filteredDepartments.length === 0 ? ( +
+ +

Отделы не найдены

+
+ ) : ( + filteredDepartments.map((department) => { + const headName = department.head + ? `${department.head.last_name} ${department.head.first_name}`.trim() + : "Не назначен"; + + return ( +
+
+
+

{department.name}

+

Руководитель: {headName}

+

{department.description || "Описание не заполнено"}

+
+
+ + {department.employees_count ?? 0} +
+
+
+ ); + }) + )} +
+
+ )} ); } diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 0486a39e..4128543b 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -1,9 +1,147 @@ +"use client"; + import { AppShell } from "../../components/AppShell"; +import { apiClient } from "@/lib/api"; +import { useEffect, useMemo, useState } from "react"; +import type { Document } from "@/types/api"; +import { Search, FileText, Download } from "lucide-react"; + +function formatDate(value?: string): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} export default function DocumentsPage() { + const [documents, setDocuments] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadDocuments() { + try { + setLoading(true); + setError(null); + const response = await apiClient.getDocuments(); + setDocuments(response.results || []); + } catch (err) { + console.error("Ошибка загрузки документов:", err); + setError("Не удалось загрузить документы"); + } finally { + setLoading(false); + } + } + + loadDocuments(); + }, []); + + const filteredDocuments = useMemo(() => { + const q = search.trim().toLowerCase(); + const sorted = [...documents].sort((a, b) => { + const aTime = new Date(a.created_at).getTime() || 0; + const bTime = new Date(b.created_at).getTime() || 0; + return bTime - aTime; + }); + + if (!q) return sorted; + + return sorted.filter((doc) => { + const title = doc.title.toLowerCase(); + const description = (doc.description || "").toLowerCase(); + const type = (doc.document_type || "").toLowerCase(); + const author = doc.created_by + ? `${doc.created_by.last_name} ${doc.created_by.first_name}`.toLowerCase() + : ""; + return title.includes(q) || description.includes(q) || type.includes(q) || author.includes(q); + }); + }, [documents, search]); + return ( -

Раздел в разработке.

+ {loading ? ( +
+
+

Загрузка документов...

+
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+
+ + setSearch(e.target.value)} + placeholder="Поиск по документам" + className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2.5 pl-9 pr-3 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" + /> +
+ +
+ {filteredDocuments.length === 0 ? ( +
+ +

Документы не найдены

+
+ ) : ( + filteredDocuments.map((doc) => { + const authorName = doc.created_by + ? `${doc.created_by.last_name} ${doc.created_by.first_name}`.trim() + : "Неизвестно"; + + return ( +
+
+
+

{doc.title}

+

Тип: {doc.document_type || "—"}

+
+ + {doc.file ? ( + + + Открыть + + ) : null} +
+ +

{doc.description || "Описание не заполнено"}

+ + {doc.tags && doc.tags.length > 0 ? ( +
+ {doc.tags.map((tag) => ( + + #{tag} + + ))} +
+ ) : null} + +
+

Автор: {authorName}

+

Создано: {formatDate(doc.created_at)}

+

Обновлено: {formatDate(doc.updated_at)}

+
+
+ ); + }) + )} +
+
+ )} ); } diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index ee93f6eb..847e5174 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -2,29 +2,78 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { User } from "@/types/api"; export default function EmployeesPage() { const [employees, setEmployees] = useState([]); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const loaderRef = useRef(null); + const isFetchingRef = useRef(false); + + const loadEmployees = async (pageToLoad: number, append: boolean) => { + if (isFetchingRef.current) return; + + try { + isFetchingRef.current = true; + if (append) { + setLoadingMore(true); + } else { + setLoading(true); + } + setError(null); + + const response = await apiClient.getEmployees({ page: pageToLoad, limit: 20 }); + const nextChunk = response.results || []; + + setEmployees((prev) => (append ? [...prev, ...nextChunk] : nextChunk)); + setHasMore(Boolean(response.next)); + setPage(pageToLoad); + } catch (err: any) { + console.error("Ошибка загрузки сотрудников:", err); + setError("Не удалось загрузить список сотрудников"); + } finally { + isFetchingRef.current = false; + setLoading(false); + setLoadingMore(false); + } + }; useEffect(() => { - async function loadEmployees() { - try { - const response = await apiClient.getEmployees(); - setEmployees(response.results); - } catch (err: any) { - console.error('Ошибка загрузки сотрудников:', err); - setError('Не удалось загрузить список сотрудников'); - } finally { - setLoading(false); - } - } - loadEmployees(); + loadEmployees(1, false); }, []); + useEffect(() => { + if (!hasMore || loading) return; + + const target = loaderRef.current; + if (!target) return; + + const observer = new IntersectionObserver( + (entries) => { + const first = entries[0]; + if (!first?.isIntersecting) return; + if (isFetchingRef.current || loadingMore || !hasMore) return; + loadEmployees(page + 1, true); + }, + { + root: null, + rootMargin: "300px 0px", + threshold: 0.1, + } + ); + + observer.observe(target); + + return () => { + observer.disconnect(); + }; + }, [page, hasMore, loading, loadingMore]); + if (loading) { return ( @@ -97,6 +146,14 @@ export default function EmployeesPage() { ); })} + +
+ {loadingMore ? ( +

Подгружаем сотрудников...

+ ) : !hasMore && employees.length > 0 ? ( +

Все сотрудники загружены

+ ) : null} +
); diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 945a0a1f..48151194 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -1,22 +1,72 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText } from "lucide-react"; +import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText, ChevronRight, Reply, Pencil, Trash2, Smile } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; import { apiClient } from "@/lib/api"; import type { Chat, Message, MessageAttachment } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; -const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ""; +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro"; -function getChatTitle(chat: Chat): string { - return chat.name?.trim() || "Диалог"; +function getUserFullName(lastName?: string, firstName?: string): string { + return `${lastName || ""} ${firstName || ""}`.trim(); } -function getChatInitials(chat: Chat): string { - const title = getChatTitle(chat); +function getInterlocutorFromParticipants(chat: Chat, currentUserId?: number) { + const participants = (chat.participants || []).filter( + (p): p is Exclude => typeof p === "object" && p !== null + ); + return participants.find((p) => p.id !== currentUserId); +} + +function getInterlocutorFromParticipantDetails(chat: Chat, currentUserId?: number) { + return (chat.participant_details || []).find((p) => p.id !== currentUserId); +} + +function getChatTitle(chat: Chat, currentUserId?: number): string { + const chatKind = chat.chat_type || chat.type; + const rawName = (chat.name || "").trim(); + + if (chatKind === "direct" || chatKind === "private" || !rawName || rawName.toLowerCase() === "диалог") { + if (chat.interlocutor?.name?.trim()) { + return chat.interlocutor.name.trim(); + } + + const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId); + if (detailsOther?.name?.trim()) { + return detailsOther.name.trim(); + } + + const other = getInterlocutorFromParticipants(chat, currentUserId); + if (other && typeof other === "object") { + const name = getUserFullName(other.last_name, other.first_name); + if (name) return name; + if (other.email) return other.email; + } + } + + return rawName || "Диалог"; +} + +function getChatAvatar(chat: Chat, currentUserId?: number): string { + const chatKind = chat.chat_type || chat.type; + if (chatKind === "direct" || chatKind === "private" || (chat.name || "").trim().toLowerCase() === "диалог") { + if (chat.interlocutor?.avatar) return chat.interlocutor.avatar; + + const detailsOther = getInterlocutorFromParticipantDetails(chat, currentUserId); + if (detailsOther?.avatar) return detailsOther.avatar; + + const other = getInterlocutorFromParticipants(chat, currentUserId); + if (other?.avatar) return other.avatar; + } + return chat.avatar || ""; +} + +function getChatInitials(chat: Chat, currentUserId?: number): string { + const title = getChatTitle(chat, currentUserId); return ( title .split(" ") @@ -123,30 +173,105 @@ function isAudioAttachment(att: MessageAttachment): boolean { return mime.startsWith("audio/") || type === "audio"; } +function normalizePossiblyEncodedUrl(value: string): string { + const input = (value || "").trim(); + if (!input) return ""; + + try { + // В API URL может приходить уже закодированным (%D0...). + // Декодируем один раз и кодируем обратно, чтобы избежать %25D0... (double-encoding). + return encodeURI(decodeURI(input)); + } catch { + return encodeURI(input); + } +} + function resolveAttachmentUrl(url?: string | null): string { if (!url) return ""; - if (/^https?:\/\//i.test(url)) return encodeURI(url); - if (url.startsWith("//")) return encodeURI(`https:${url}`); + if (url.startsWith("data:")) return url; + if (/^https?:\/\//i.test(url)) return normalizePossiblyEncodedUrl(url); + if (url.startsWith("//")) return normalizePossiblyEncodedUrl(`https:${url}`); if (url.startsWith("/") && BACKEND_URL) { - return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}${url}`); + return normalizePossiblyEncodedUrl(`${BACKEND_URL.replace(/\/$/, "")}${url}`); } - return encodeURI(url); + if (BACKEND_URL) { + return normalizePossiblyEncodedUrl(`${BACKEND_URL.replace(/\/$/, "")}/${url.replace(/^\/+/, "")}`); + } + return normalizePossiblyEncodedUrl(url); } function resolveAvatarUrl(url?: string | null): string { if (!url) return ""; - if (/^https?:\/\//i.test(url)) return encodeURI(url); - if (url.startsWith("//")) return encodeURI(`https:${url}`); + if (url.startsWith("data:")) return url; + if (/^https?:\/\//i.test(url)) return normalizePossiblyEncodedUrl(url); + if (url.startsWith("//")) return normalizePossiblyEncodedUrl(`https:${url}`); if (url.startsWith("/") && BACKEND_URL) { - return encodeURI(`${BACKEND_URL.replace(/\/$/, "")}${url}`); + return normalizePossiblyEncodedUrl(`${BACKEND_URL.replace(/\/$/, "")}${url}`); } - return encodeURI(url); + if (BACKEND_URL) { + return normalizePossiblyEncodedUrl(`${BACKEND_URL.replace(/\/$/, "")}/${url.replace(/^\/+/, "")}`); + } + return normalizePossiblyEncodedUrl(url); +} + +function uniqueMessagesById(items: Message[]): Message[] { + const map = new Map(); + items.forEach((msg) => { + map.set(msg.id, msg); + }); + return Array.from(map.values()); +} + +function getMessagePreviewText(message: Message): string { + if (message.content?.trim()) return message.content.trim(); + if (message.attachments?.length) return "[Вложение]"; + return "[Сообщение]"; +} + +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") { + return message.reply_to.id; + } + if (message.reply_to_message && typeof message.reply_to_message.id === "number") { + return message.reply_to_message.id; + } + return null; +} + +type ReplyTarget = { + id: number; + author: string; + preview: string; +}; + +const RECENT_REACTIONS_KEY = "eusrr_recent_reactions"; +const MAX_RECENT_REACTIONS = 5; +const ALL_REACTIONS = [ + "👍", "❤️", "😂", "🔥", "👏", "🎉", "😊", "😉", "😁", "🤝", + "🙏", "😮", "😢", "😡", "💯", "✅", "👀", "🤔", "😍", "😎", + "🤩", "🥳", "😴", "🫡", "👌", "💪", "🙌", "🧠", "💡", "🚀", + "🎯", "⭐", "✨", "🌟", "🫶", "🤗", "😅", "🤯", "🥲", "🫠", +]; + +function uniqueEmoji(items: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + + items.forEach((item) => { + if (!item || seen.has(item)) return; + seen.add(item); + out.push(item); + }); + + return out; } export default function MessageDialogPage() { const params = useParams<{ chatId: string }>(); const chatId = Number(params.chatId); - const { user } = useUser(); + const { user, loading: userLoading } = useUser(); const [chat, setChat] = useState(null); const [messages, setMessages] = useState([]); @@ -155,22 +280,147 @@ export default function MessageDialogPage() { const [error, setError] = useState(null); const [messageText, setMessageText] = useState(""); const [sending, setSending] = useState(false); + const [editingMessageId, setEditingMessageId] = useState(null); + const [replyTo, setReplyTo] = useState(null); + const [expandedReplyActionForId, setExpandedReplyActionForId] = useState(null); + const [actionsMenuAnchor, setActionsMenuAnchor] = useState<{ x: number; y: number } | null>(null); + const [reactionPickerForMessageId, setReactionPickerForMessageId] = useState(null); + const [showComposerEmojiPicker, setShowComposerEmojiPicker] = useState(false); + const [recentReactions, setRecentReactions] = useState(ALL_REACTIONS.slice(0, MAX_RECENT_REACTIONS)); const [attachedFiles, setAttachedFiles] = useState([]); const [brokenMedia, setBrokenMedia] = useState>({}); + const [useOriginalImage, setUseOriginalImage] = useState>({}); const [mediaPreview, setMediaPreview] = useState<{ type: "image" | "video"; src: string; name: string } | null>(null); const fileInputRef = useRef(null); + const messageInputRef = useRef(null); const messagesViewportRef = useRef(null); + const [hasMoreOlder, setHasMoreOlder] = useState(false); + const [hasMoreNewer, setHasMoreNewer] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [loadingNewer, setLoadingNewer] = useState(false); const [initialAnchorId, setInitialAnchorId] = useState(null); + const [initialAnchorIndex, setInitialAnchorIndex] = useState(null); + const [allowOneOlderProbe, setAllowOneOlderProbe] = useState(false); + const initialScrolledRef = useRef(false); + const anchorScrollAttemptsRef = useRef(0); + const anchorOlderLoadAttemptsRef = useRef(0); + const prependAdjustRef = useRef<{ prevHeight: number; prevTop: number } | null>(null); + const [anchorRetryTick, setAnchorRetryTick] = useState(0); + const messagesById = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); + const selectedActionMessage = expandedReplyActionForId ? messagesById.get(expandedReplyActionForId) || null : null; + const selectedActionCanManage = Boolean( + selectedActionMessage && + user?.id && + !selectedActionMessage.is_deleted && + (selectedActionMessage.author_id === user.id || + selectedActionMessage.author?.id === user.id || + selectedActionMessage.sender?.id === user.id) + ); + const selectedActionCanReply = Boolean(selectedActionMessage && !selectedActionMessage.is_deleted); + const selectedActionRecentReactions = useMemo( + () => uniqueEmoji(recentReactions).slice(0, MAX_RECENT_REACTIONS), + [recentReactions] + ); + + useLayoutEffect(() => { + if (typeof window === "undefined" || !window.history) return; + const prev = window.history.scrollRestoration; + window.history.scrollRestoration = "manual"; + return () => { + window.history.scrollRestoration = prev; + }; + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + const raw = localStorage.getItem(RECENT_REACTIONS_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return; + const normalized = uniqueEmoji(parsed.filter((v): v is string => typeof v === "string")).slice(0, MAX_RECENT_REACTIONS); + if (normalized.length > 0) { + setRecentReactions(normalized); + } + } catch { + // ignore + } + }, []); + + const pushRecentReaction = (emoji: string) => { + const next = uniqueEmoji([emoji, ...recentReactions]).slice(0, MAX_RECENT_REACTIONS); + setRecentReactions(next); + if (typeof window !== "undefined") { + localStorage.setItem(RECENT_REACTIONS_KEY, JSON.stringify(next)); + } + }; + + const appendEmojiToComposer = (emoji: string) => { + setMessageText((prev) => `${prev}${emoji}`); + requestAnimationFrame(() => { + const input = messageInputRef.current; + if (!input) return; + input.focus(); + const len = input.value.length; + input.setSelectionRange(len, len); + }); + }; + + const updateMessageReactionsSummary = ( + messageId: number, + summary?: Record + ) => { + if (!summary) return; + setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, reactions_summary: summary } : m))); + }; + + const hasMyReaction = (message: Message, emoji: string): boolean => { + if (!user?.id) return false; + const users = message.reactions_summary?.[emoji]?.users || []; + return users.includes(user.id); + }; + + const handleReact = async (message: Message, emoji: string) => { + try { + const response = hasMyReaction(message, emoji) + ? await apiClient.unreactToMessage(message.id, emoji) + : await apiClient.reactToMessage(message.id, emoji); + + pushRecentReaction(emoji); + updateMessageReactionsSummary(message.id, response.reactions_summary); + } catch (e) { + console.error("Ошибка реакции:", e); + } + }; useEffect(() => { async function loadChats() { + if (!chatId || Number.isNaN(chatId)) { + setChat(null); + setLoading(false); + return; + } + + // На full reload дожидаемся восстановления auth-состояния, + // иначе возможен первый 401 и «залипание» без повторной загрузки. + if (userLoading) { + setLoading(true); + return; + } + + if (!user) { + setChat(null); + setLoading(false); + return; + } + try { setLoading(true); setError(null); - const response = await apiClient.getChats(); - const items = response.results || []; - setChat(items.find((c) => c.id === chatId) || null); + const loadedChat = await apiClient.getChat(chatId); + setChat(loadedChat); } catch (e: any) { + setChat(null); setError("Не удалось загрузить чат. Проверьте подключение и попробуйте снова."); } finally { setLoading(false); @@ -178,29 +428,57 @@ export default function MessageDialogPage() { } loadChats(); - }, [chatId]); + }, [chatId, userLoading, user?.id]); useEffect(() => { async function loadMessages() { - if (!chatId || Number.isNaN(chatId) || !chat) { + if (!chatId || Number.isNaN(chatId)) { setMessages([]); + setHasMoreOlder(false); + setHasMoreNewer(false); setInitialAnchorId(null); + setInitialAnchorIndex(null); + setAllowOneOlderProbe(false); + initialScrolledRef.current = false; + return; + } + + // Ключевой фикс для полного reload: грузим сообщения только + // после завершения инициализации пользователя. + if (userLoading || !user) { return; } try { setMessagesLoading(true); - const around = await apiClient.getChatMessagesAround(chatId, { limit: 40 }); + const around = await apiClient.getChatMessagesAround(chatId, { limit: 50 }); const aroundMessages = around.messages || []; if (aroundMessages.length > 0) { - setMessages(aroundMessages); + const normalized = uniqueMessagesById(aroundMessages); + setMessages(normalized); + // fallback на случай, если API не вернул флаг has_more_before + setHasMoreOlder( + typeof around.has_more_before === "boolean" ? around.has_more_before : normalized.length >= 50 + ); + setHasMoreNewer(Boolean(around.has_more_after)); setInitialAnchorId(around.anchor_id ?? null); + setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : null); + setAllowOneOlderProbe(Boolean(around.anchor_id)); } else { const response = await apiClient.getChatMessages(chatId, { limit: 50 }); - setMessages(response.messages || []); + setMessages(uniqueMessagesById(response.messages || [])); + setHasMoreOlder(Boolean(response.has_more)); + setHasMoreNewer(false); setInitialAnchorId(null); + setInitialAnchorIndex(null); + setAllowOneOlderProbe(false); } + + initialScrolledRef.current = false; + anchorScrollAttemptsRef.current = 0; + anchorOlderLoadAttemptsRef.current = 0; + setAnchorRetryTick(0); } catch (e: any) { if (String(e?.message || "").includes("403")) { setError("Нет доступа к этому чату"); @@ -208,17 +486,25 @@ export default function MessageDialogPage() { } else { console.error("Ошибка загрузки сообщений:", e); } + setHasMoreOlder(false); + setHasMoreNewer(false); setInitialAnchorId(null); + setInitialAnchorIndex(null); + setAllowOneOlderProbe(false); + initialScrolledRef.current = false; + anchorScrollAttemptsRef.current = 0; + anchorOlderLoadAttemptsRef.current = 0; + setAnchorRetryTick(0); } finally { setMessagesLoading(false); } } loadMessages(); - }, [chatId, chat]); + }, [chatId, userLoading, user?.id]); useEffect(() => { - if (messagesLoading || !messagesViewportRef.current || messages.length === 0) return; + if (messagesLoading || !messagesViewportRef.current || messages.length === 0 || initialScrolledRef.current) return; const viewport = messagesViewportRef.current; @@ -226,29 +512,200 @@ export default function MessageDialogPage() { const el = viewport.querySelector(`[data-message-id="${initialAnchorId}"]`) as HTMLElement | null; if (el) { el.scrollIntoView({ block: "center" }); + initialScrolledRef.current = true; + anchorScrollAttemptsRef.current = 0; + // Якорь уже применили один раз — больше к нему не возвращаемся + setInitialAnchorId(null); + setInitialAnchorIndex(null); + return; + } + + if (typeof initialAnchorIndex === "number" && initialAnchorIndex >= 0) { + const children = viewport.querySelectorAll("[data-message-id]"); + const byIndex = children.item(initialAnchorIndex) as HTMLElement | null; + if (byIndex) { + byIndex.scrollIntoView({ block: "center" }); + initialScrolledRef.current = true; + anchorScrollAttemptsRef.current = 0; + // Якорь уже применили один раз — больше к нему не возвращаемся + setInitialAnchorId(null); + setInitialAnchorIndex(null); + return; + } + } + + // На reload DOM иногда дорисовывается чуть позже — повторяем поиск якоря несколько раз + if (anchorScrollAttemptsRef.current < 8) { + anchorScrollAttemptsRef.current += 1; + setTimeout(() => setAnchorRetryTick((v) => v + 1), 40); + return; + } + + // Если якорь не найден в текущей выборке, пробуем автоматически догрузить старые + if ((hasMoreOlder || allowOneOlderProbe) && anchorOlderLoadAttemptsRef.current < 3 && !loadingOlder) { + anchorOlderLoadAttemptsRef.current += 1; + loadOlderMessages(); + setTimeout(() => setAnchorRetryTick((v) => v + 1), 60); return; } } viewport.scrollTop = viewport.scrollHeight; - }, [messagesLoading, messages, initialAnchorId]); + initialScrolledRef.current = true; + anchorScrollAttemptsRef.current = 0; + anchorOlderLoadAttemptsRef.current = 0; + // Если якорь не найден и ушли в fallback вниз — очищаем якорь, + // чтобы последующая дозагрузка не пыталась снова скроллить к нему. + if (initialAnchorId || initialAnchorIndex !== null) { + setInitialAnchorId(null); + setInitialAnchorIndex(null); + } + }, [ + messagesLoading, + messages, + initialAnchorId, + initialAnchorIndex, + anchorRetryTick, + hasMoreOlder, + allowOneOlderProbe, + loadingOlder, + ]); + + useLayoutEffect(() => { + const pending = prependAdjustRef.current; + if (!pending) return; + + const vp = messagesViewportRef.current; + if (!vp) { + prependAdjustRef.current = null; + return; + } + + const { prevHeight, prevTop } = pending; + const delta = vp.scrollHeight - prevHeight; + vp.scrollTop = Math.max(0, prevTop + delta); + prependAdjustRef.current = null; + }, [messages]); + + const loadOlderMessages = async () => { + if (!chatId || Number.isNaN(chatId) || loadingOlder || messagesLoading || messages.length === 0) { + return; + } + + if (!hasMoreOlder && !allowOneOlderProbe) { + return; + } + + const oldestMessage = messages[0]; + const viewport = messagesViewportRef.current; + const prevHeight = viewport?.scrollHeight || 0; + const prevTop = viewport?.scrollTop || 0; + + try { + setLoadingOlder(true); + const response = await apiClient.getChatMessages(chatId, { + limit: 40, + before_id: oldestMessage.id, + }); + + const olderMessages = response.messages || []; + if (olderMessages.length > 0) { + prependAdjustRef.current = { prevHeight, prevTop }; + setMessages((prev) => uniqueMessagesById([...olderMessages, ...prev])); + } + + setHasMoreOlder(Boolean(response.has_more)); + setAllowOneOlderProbe(false); + if (!response.has_more && (!response.messages || response.messages.length === 0)) { + anchorOlderLoadAttemptsRef.current = 3; + } + } catch (e) { + console.error("Ошибка подгрузки старых сообщений:", e); + } finally { + setLoadingOlder(false); + } + }; + + const loadNewerMessages = async () => { + if (!chatId || Number.isNaN(chatId) || loadingNewer || messagesLoading || !hasMoreNewer || messages.length === 0) { + return; + } + + const newestMessage = messages[messages.length - 1]; + + try { + setLoadingNewer(true); + const response = await apiClient.getChatMessages(chatId, { + limit: 40, + after_id: newestMessage.id, + }); + + const newerMessages = response.messages || []; + if (newerMessages.length > 0) { + setMessages((prev) => uniqueMessagesById([...prev, ...newerMessages])); + } + + setHasMoreNewer(Boolean(response.has_more)); + } catch (e) { + console.error("Ошибка подгрузки новых сообщений:", e); + } finally { + setLoadingNewer(false); + } + }; + + useEffect(() => { + const viewport = messagesViewportRef.current; + if (!viewport) return; + + const onScroll = () => { + if (viewport.scrollTop <= 120) { + loadOlderMessages(); + } + + if (viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= 120) { + loadNewerMessages(); + } + }; + + viewport.addEventListener("scroll", onScroll, { passive: true }); + return () => viewport.removeEventListener("scroll", onScroll); + }, [chatId, hasMoreOlder, hasMoreNewer, loadingOlder, loadingNewer, messagesLoading, messages]); const handleSend = async () => { const text = messageText.trim(); if (!chatId || sending) return; - if (!text && attachedFiles.length === 0) return; + if (editingMessageId) { + if (!text) return; + } else if (!text && attachedFiles.length === 0) { + return; + } try { setSending(true); - const sent = attachedFiles.length - ? await apiClient.sendMessageWithFiles(chatId, text, attachedFiles) - : await apiClient.sendMessage(chatId, text); - setMessages((prev) => [...prev, sent]); + if (editingMessageId) { + const updated = await apiClient.updateMessage(editingMessageId, text); + setMessages((prev) => prev.map((m) => (m.id === editingMessageId ? { ...m, ...updated } : m))); + } else { + const sent = attachedFiles.length + ? await apiClient.sendMessageWithFiles(chatId, text, attachedFiles, replyTo?.id) + : await apiClient.sendMessage(chatId, text, replyTo?.id); + setMessages((prev) => uniqueMessagesById([...prev, sent])); + } + + setEditingMessageId(null); setMessageText(""); + setReplyTo(null); + setExpandedReplyActionForId(null); setAttachedFiles([]); if (fileInputRef.current) { fileInputRef.current.value = ""; } + + requestAnimationFrame(() => { + const viewport = messagesViewportRef.current; + if (!viewport) return; + viewport.scrollTop = viewport.scrollHeight; + }); } catch (e) { console.error("Ошибка отправки сообщения:", e); } finally { @@ -257,6 +714,7 @@ export default function MessageDialogPage() { }; const handlePickFiles = () => { + if (editingMessageId) return; fileInputRef.current?.click(); }; @@ -270,16 +728,141 @@ export default function MessageDialogPage() { setAttachedFiles((prev) => prev.filter((_, i) => i !== index)); }; + const focusMessageInput = () => { + const input = messageInputRef.current; + if (!input) return; + input.focus(); + const len = input.value.length; + input.setSelectionRange(len, len); + }; + + const handleReplyToMessage = (message: Message) => { + const author = message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"; + const preview = getMessagePreviewText(message); + setEditingMessageId(null); + setReplyTo({ id: message.id, author, preview }); + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + requestAnimationFrame(focusMessageInput); + }; + + const canManageMessage = (message: Message): boolean => { + if (!user?.id || message.is_deleted) return false; + return message.author_id === user.id || message.author?.id === user.id || message.sender?.id === user.id; + }; + + const handleStartEditMessage = (message: Message) => { + if (!canManageMessage(message)) return; + + setEditingMessageId(message.id); + setReplyTo(null); + setExpandedReplyActionForId(null); + setMessageText(message.content || ""); + setAttachedFiles([]); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + + requestAnimationFrame(focusMessageInput); + }; + + const handleCancelEdit = () => { + setEditingMessageId(null); + setMessageText(""); + }; + + const handleDeleteMessage = async (message: Message) => { + if (!canManageMessage(message)) return; + + try { + await apiClient.deleteMessage(message.id); + + setMessages((prev) => + prev.map((m) => + m.id === message.id + ? { + ...m, + content: "", + attachments: [], + has_attachments: false, + is_deleted: true, + } + : m + ) + ); + + if (replyTo?.id === message.id) { + setReplyTo(null); + } + if (editingMessageId === message.id) { + setEditingMessageId(null); + setMessageText(""); + } + + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + } catch (e) { + console.error("Ошибка удаления сообщения:", e); + } + }; + + useEffect(() => { + const onWindowClick = (e: MouseEvent) => { + const target = e.target as HTMLElement | null; + if (!target) return; + if (target.closest("[data-actions-menu='true']")) return; + if (target.closest("[data-actions-trigger='true']")) return; + if (target.closest("[data-reaction-picker='true']")) return; + if (target.closest("[data-composer-emoji='true']")) return; + + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + setReactionPickerForMessageId(null); + setShowComposerEmojiPicker(false); + }; + + window.addEventListener("mousedown", onWindowClick); + return () => window.removeEventListener("mousedown", onWindowClick); + }, []); + + const isInteractiveArea = (target: HTMLElement): boolean => + Boolean(target.closest("textarea, input, button, a, video, audio, [contenteditable='true']")); + useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setMediaPreview(null); + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + setReactionPickerForMessageId(null); + setShowComposerEmojiPicker(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); + useEffect(() => { + const onGlobalWheel = (e: WheelEvent) => { + if (e.defaultPrevented || mediaPreview) return; + + const viewport = messagesViewportRef.current; + if (!viewport) return; + + const target = e.target as HTMLElement | null; + if (!target) return; + + if (viewport.contains(target)) return; + if (isInteractiveArea(target)) return; + + e.preventDefault(); + viewport.scrollTop += e.deltaY; + }; + + window.addEventListener("wheel", onGlobalWheel, { passive: false }); + return () => window.removeEventListener("wheel", onGlobalWheel); + }, [mediaPreview]); + return ( {loading ? ( @@ -292,21 +875,21 @@ export default function MessageDialogPage() {

{error}

) : ( -
+
{chat ? ( <>
- {chat.avatar ? ( - {getChatTitle(chat)} + {getChatAvatar(chat, user?.id) ? ( + {getChatTitle(chat, ) : ( - getChatInitials(chat) + getChatInitials(chat, user?.id) )}
-

{getChatTitle(chat)}

+

{getChatTitle(chat, user?.id)}

{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}

@@ -322,26 +905,39 @@ export default function MessageDialogPage() {
-
+
{messagesLoading ? (

Загрузка сообщений...

) : messages.length === 0 ? (

Пока нет сообщений. Напишите первым.

) : (
+ {loadingOlder ? ( +

Подгружаем старые сообщения...

+ ) : null} {messages.map((message, index) => { const currentDate = getMessageDate(message); const prevDate = index > 0 ? getMessageDate(messages[index - 1]) : null; const showDayDivider = index === 0 || !isSameDay(currentDate, prevDate); + const replyToId = getReplyToId(message); + const repliedMessage = replyToId ? messagesById.get(replyToId) : null; + const isReplyMenuOpen = expandedReplyActionForId === message.id; const isMine = user?.id && (message.author_id === user.id || message.author?.id === user.id || message.sender?.id === user.id); + const canManage = canManageMessage(message); + const canReply = !message.is_deleted; + const hasActions = canReply || canManage; return ( -
+
{showDayDivider && currentDate ? ( -
+
{formatDayDivider(currentDate)} @@ -369,95 +965,213 @@ export default function MessageDialogPage() { ) : null}
- {!isMine ? ( -

- {message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"} -

- ) : null} +
+ {hasActions ? ( +
+ +
+ ) : null} - {message.attachments && message.attachments.length > 0 ? ( -
- {message.attachments.map((att) => ( -
- {isImageAttachment(att) ? ( - - ) : isVideoAttachment(att) ? ( - brokenMedia[att.id] ? ( + {!isMine ? ( +

+ {message.author_name || message.author?.last_name || message.sender?.last_name || "Сотрудник"} +

+ ) : null} + + {replyToId ? ( +
+

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

+

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

+
+ ) : null} + + {message.is_deleted ? ( +

Сообщение удалено

+ ) : message.content ? ( +

{message.content}

+ ) : null} + + {message.attachments && message.attachments.length > 0 ? ( +
+ {message.attachments.map((att) => ( +
+ {isImageAttachment(att) ? ( + brokenMedia[att.id] ? ( + + + Предпросмотр недоступен — открыть файл + + ) : ( + + ) + ) : isVideoAttachment(att) ? ( + brokenMedia[att.id] ? ( + + + + Видео не поддерживается в браузере — открыть файл + + + ) : ( + + ) + ) : isAudioAttachment(att) ? ( + brokenMedia[att.id] ? ( + + + Аудио не поддерживается — открыть файл + + ) : ( + + ) + ) : ( - - Видео не поддерживается в браузере — открыть файл - + {att.file_name} + {formatFileSize(att.file_size)} - ) : ( - - ) - ) : isAudioAttachment(att) ? ( - - ) : ( - - - {att.file_name} - {formatFileSize(att.file_size)} - - )} -
- ))} -
- ) : null} + )} +
+ ))} +
+ ) : null} + +

+ {messageTime(message)} +

+ + {Object.keys(message.reactions_summary || {}).length > 0 ? ( +
+ {Object.entries(message.reactions_summary || {}).map(([emoji, meta]) => { + const mine = hasMyReaction(message, emoji); + return ( + + ); + })} +
+ ) : null} +
-

- {messageTime(message)} -

@@ -468,6 +1182,40 @@ export default function MessageDialogPage() {
+ {editingMessageId ? ( +
+
+

Режим редактирования

+

Измените текст сообщения и отправьте

+
+ +
+ ) : null} + + {replyTo ? ( +
+
+

Ответ: {replyTo.author}

+

{replyTo.preview}

+
+ +
+ ) : null} + {attachedFiles.length > 0 ? (
{attachedFiles.map((file, index) => ( @@ -500,30 +1248,66 @@ export default function MessageDialogPage() { -