diff --git a/backend/api/v1/communications/serializers.py b/backend/api/v1/communications/serializers.py index 9b86288d..258011dc 100644 --- a/backend/api/v1/communications/serializers.py +++ b/backend/api/v1/communications/serializers.py @@ -110,6 +110,8 @@ class ChatDetailSerializer(serializers.ModelSerializer): participant_details = serializers.SerializerMethodField() memberships = ChatMembershipSerializer(many=True, read_only=True, source='chatmembership_set') user_settings = serializers.SerializerMethodField() + is_pinned = serializers.SerializerMethodField() + notifications_enabled = serializers.SerializerMethodField() last_read_message_id = serializers.SerializerMethodField() class Meta: @@ -119,7 +121,8 @@ class ChatDetailSerializer(serializers.ModelSerializer): 'created_by', 'created_at', 'is_main', 'include_all_employees', 'department', 'participants', 'participant_details', - 'memberships', 'user_settings', 'last_read_message_id' + 'memberships', 'user_settings', 'is_pinned', + 'notifications_enabled', 'last_read_message_id' ] read_only_fields = ['created_at', 'created_by'] @@ -141,6 +144,22 @@ class ChatDetailSerializer(serializers.ModelSerializer): return ChatUserSettingsSerializer(settings).data return {'is_pinned': False, 'notifications_enabled': True} + def get_is_pinned(self, obj): + """Закреплен ли чат для текущего пользователя""" + request = self.context.get('request') + if request and request.user.is_authenticated: + settings = obj.user_settings.filter(user=request.user).first() + return settings.is_pinned if settings else False + return False + + def get_notifications_enabled(self, obj): + """Включены ли уведомления для текущего пользователя""" + request = self.context.get('request') + if request and request.user.is_authenticated: + settings = obj.user_settings.filter(user=request.user).first() + return settings.notifications_enabled if settings else True + return True + def get_last_read_message_id(self, obj): """ID последнего прочитанного сообщения для текущего пользователя""" from communications.models import ChatReadState diff --git a/backend/communications/notifications/config.py b/backend/communications/notifications/config.py index e359038a..a1a07cc9 100644 --- a/backend/communications/notifications/config.py +++ b/backend/communications/notifications/config.py @@ -69,19 +69,17 @@ class ActionURLs: def chat_detail(chat_id: int) -> str: """ Возвращает URL-адрес чата. - - TODO: реализовать прямые ссылки на конкретные чаты """ - return f'/messages?chat={chat_id}' + return f'/messages/{chat_id}' @staticmethod def message_detail(chat_id: int, message_id: int) -> str: """ Возвращает URL-адрес сообщения. - TODO: реализовать прямые ссылки на конкретные сообщения + В будущем можно добавить прокрутку к конкретному сообщению. """ - return f'/messages?chat={chat_id}&message={message_id}' + return f'/messages/{chat_id}?message={message_id}' # ===== Вспомогательные функции ===== diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index cae8f8b9..11a4e586 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -101,7 +101,7 @@ def notify_new_message(message): verb=NotificationVerbs.MENTION, action_object=message, description=truncate_message(content, 100), - action_url=ActionURLs.MESSAGES, + action_url=ActionURLs.chat_detail(chat.id), data={ 'title': MessageTemplates.mention(author_name), 'chat_id': chat.id, @@ -126,7 +126,7 @@ def notify_new_message(message): verb=NotificationVerbs.REPLY, action_object=message, description=truncate_message(content, 100), - action_url=ActionURLs.MESSAGES, + action_url=ActionURLs.chat_detail(chat.id), data={ 'title': MessageTemplates.reply(author_name), 'chat_id': chat.id, @@ -177,7 +177,7 @@ def notify_new_message(message): verb=notification_verb, action_object=message, description=truncate_message(content, max_length), - action_url=ActionURLs.MESSAGES, + action_url=ActionURLs.chat_detail(chat.id), data={**metadata, 'title': title}, ) @@ -210,7 +210,7 @@ def notify_chat_added(chat, new_users, added_by=None): verb=NotificationVerbs.ADDED_TO_CHAT, action_object=chat, description=MessageTemplates.added_to_chat(get_chat_name(chat)), - action_url=ActionURLs.MESSAGES, + action_url=ActionURLs.chat_detail(chat.id), data={ 'title': MessageTemplates.added_to_chat_title(), 'chat_id': chat.id, diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index 858924f3..a958e7b8 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -3,18 +3,17 @@ from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path -# Импорт Service Worker view из приложения notifications -from notifications.views import serve_service_worker - urlpatterns = [ path("admin/", admin.site.urls), path("api/", include(("api.urls", "api"), namespace="api")), - # Service Worker должен быть в корне для правильного scope - path("sw.js", serve_service_worker, name="sw"), - # django-filer URLs для обработки приватных файлов (с проверкой прав доступа) + # django-filer URLs для обработки приватных файлов + # (с проверкой прав доступа) path("", include("filer.server.urls")), ] -# Публичные медиа файлы (без проверки прав) - только в режиме разработки +# Публичные медиа файлы (без проверки прав) +# только в режиме разработки if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static( + settings.MEDIA_URL, document_root=settings.MEDIA_ROOT + ) diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 1a7589ee..d78f53fe 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText, ChevronRight, ChevronDown, Reply, Pencil, Trash2, Smile } from "lucide-react"; +import { MessageCircle, Send, ArrowLeft, Paperclip, X, FileText, ChevronRight, ChevronDown, Reply, Pencil, Trash2, Smile, Pin, Bell, BellOff } from "lucide-react"; import { AppShell } from "../../../components/AppShell"; import { apiClient } from "@/lib/api"; import type { Chat, Message, MessageAttachment } from "@/types/api"; @@ -286,11 +286,23 @@ export default function MessageDialogPage() { const [anchorRetryTick, setAnchorRetryTick] = useState(0); const hasMoreNewerRef = useRef(false); + // Локальное состояние для настроек чата + const [isPinned, setIsPinned] = useState(false); + const [notificationsEnabled, setNotificationsEnabled] = useState(true); + // Синхронизируем ref с state useEffect(() => { hasMoreNewerRef.current = hasMoreNewer; }, [hasMoreNewer]); + // Синхронизация настроек чата + useEffect(() => { + if (chat) { + setIsPinned(chat.is_pinned ?? false); + setNotificationsEnabled(chat.notifications_enabled ?? true); + } + }, [chat]); + const messagesById = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); const selectedActionMessage = expandedReplyActionForId ? messagesById.get(expandedReplyActionForId) || null : null; const selectedActionCanManage = Boolean( @@ -999,6 +1011,40 @@ export default function MessageDialogPage() { } }; + const handleTogglePin = async () => { + if (!chatId) return; + + try { + const response = await apiClient.togglePinChat(chatId); + const newIsPinned = response.is_pinned ?? !isPinned; + setIsPinned(newIsPinned); + + // Обновляем chat объект + if (chat) { + setChat({ ...chat, is_pinned: newIsPinned }); + } + } catch (e) { + console.error("Ошибка переключения закрепления:", e); + } + }; + + const handleToggleNotifications = async () => { + if (!chatId) return; + + try { + const response = await apiClient.toggleChatNotifications(chatId); + const newNotificationsEnabled = response.notifications_enabled ?? !notificationsEnabled; + setNotificationsEnabled(newNotificationsEnabled); + + // Обновляем chat объект + if (chat) { + setChat({ ...chat, notifications_enabled: newNotificationsEnabled }); + } + } catch (e) { + console.error("Ошибка переключения уведомлений:", e); + } + }; + useEffect(() => { const onWindowClick = (e: MouseEvent) => { const target = e.target as HTMLElement | null; @@ -1138,14 +1184,47 @@ export default function MessageDialogPage() { - - - +
+ {/* Кнопка закрепления чата */} + + + {/* Кнопка управления уведомлениями */} + + + {/* Кнопка возврата к списку */} + + + +
diff --git a/frontend/src/lib/verbTranslations.ts b/frontend/src/lib/verbTranslations.ts index 98b278b3..34b6d398 100644 --- a/frontend/src/lib/verbTranslations.ts +++ b/frontend/src/lib/verbTranslations.ts @@ -41,6 +41,8 @@ export const VERB_CATEGORIES: Record = { // Новости feed_new_post: 'Новости', + feed_post_comment: 'Новости', + feed_post_reaction: 'Новости', // Системные system_announcement: 'Система', @@ -83,6 +85,8 @@ export const VERB_NAMES: Record = { // Новости feed_new_post: 'Новая публикация', + feed_post_comment: 'Комментарий к публикации', + feed_post_reaction: 'Реакция на публикацию', // Системные system_announcement: 'Системное объявление', diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index bc0d9499..adccdfe1 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -367,6 +367,8 @@ export interface Chat { last_message?: Message; unread_count?: number; created_at: string; + is_pinned?: boolean; + notifications_enabled?: boolean; } export interface Message {