---------------------

This commit is contained in:
lizerk 2026-03-10 17:00:51 +03:00
parent b0e88e8e8b
commit 96e375cd6f
7 changed files with 128 additions and 27 deletions

View File

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

View File

@ -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}'
# ===== Вспомогательные функции =====

View File

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

View File

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

View File

@ -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() {
</div>
</div>
<Link
href="/messages"
aria-label="К списку чатов"
title="К списку чатов"
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
>
<ArrowLeft size={16} />
</Link>
<div className="flex items-center gap-2">
{/* Кнопка закрепления чата */}
<button
type="button"
onClick={handleTogglePin}
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
isPinned
? "border-sky-500 bg-sky-50 text-sky-700 hover:bg-sky-100"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
}`}
title={isPinned ? "Открепить чат" : "Закрепить чат"}
aria-label={isPinned ? "Открепить чат" : "Закрепить чат"}
>
<Pin size={16} className={isPinned ? "fill-current" : ""} />
</button>
{/* Кнопка управления уведомлениями */}
<button
type="button"
onClick={handleToggleNotifications}
className={`flex h-10 w-10 items-center justify-center rounded-full border transition ${
notificationsEnabled
? "border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-sky-700"
: "border-amber-500 bg-amber-50 text-amber-700 hover:bg-amber-100"
}`}
title={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
aria-label={notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
>
{notificationsEnabled ? <Bell size={16} /> : <BellOff size={16} />}
</button>
{/* Кнопка возврата к списку */}
<Link
href="/messages"
aria-label="К списку чатов"
title="К списку чатов"
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
>
<ArrowLeft size={16} />
</Link>
</div>
</header>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">

View File

@ -41,6 +41,8 @@ export const VERB_CATEGORIES: Record<string, string> = {
// Новости
feed_new_post: 'Новости',
feed_post_comment: 'Новости',
feed_post_reaction: 'Новости',
// Системные
system_announcement: 'Система',
@ -83,6 +85,8 @@ export const VERB_NAMES: Record<string, string> = {
// Новости
feed_new_post: 'Новая публикация',
feed_post_comment: 'Комментарий к публикации',
feed_post_reaction: 'Реакция на публикацию',
// Системные
system_announcement: 'Системное объявление',

View File

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