From 53cf1918c0ec8af9a204e9cebb919e9b38e84f7c Mon Sep 17 00:00:00 2001
From: lizerk
Date: Mon, 17 Aug 2026 16:08:14 +0300
Subject: [PATCH] Add message mentions and unread mention tracking
---
backend/communications/api/serializers.py | 50 ++-
backend/communications/api/viewsets.py | 75 ++++-
backend/communications/consumers.py | 62 +++-
backend/communications/mentions.py | 194 ++++++++++++
.../migrations/0040_message_mentions.py | 20 ++
...0041_chatreadstate_unread_mention_count.py | 62 ++++
backend/communications/models.py | 20 +-
.../communications/notifications/handlers.py | 93 ++++--
backend/communications/serialization.py | 1 +
.../services/message_deletion.py | 8 +
backend/communications/signals.py | 22 +-
.../docs/architecture/websocket-asyncapi.yaml | 30 ++
.../api/v1/communications/test_mentions.py | 295 ++++++++++++++++++
docs/guides/API.md | 14 +
docs/guides/NOTIFICATIONS.md | 3 +-
docs/guides/WEBSOCKET.md | 6 +-
frontend/src/app/globals.css | 7 +
frontend/src/app/messages/[chatId]/page.tsx | 32 +-
.../components/messages/ChatMessageItem.tsx | 37 ++-
.../components/messages/MessageComposer.tsx | 181 ++++++++++-
.../messages/MessagesChatListItem.tsx | 22 ++
.../components/shared/SearchableSelect.tsx | 13 +-
.../components/tasks/TaskBoardNavigator.tsx | 4 +-
frontend/src/hooks/useMessagesChatList.ts | 7 +-
frontend/src/lib/api/messages.ts | 15 +-
frontend/src/lib/chat-list-state.test.js | 2 +
frontend/src/lib/dropdown-position.test.js | 28 ++
frontend/src/lib/dropdown-position.ts | 27 ++
frontend/src/lib/messages/chatListState.js | 1 +
frontend/src/lib/messages/mentions.test.js | 56 ++++
frontend/src/lib/messages/mentions.ts | 79 +++++
.../src/lib/navigation-notifications.test.js | 4 +
frontend/src/lib/verbTranslations.ts | 6 +-
frontend/src/types/api.ts | 16 +
34 files changed, 1408 insertions(+), 84 deletions(-)
create mode 100644 backend/communications/mentions.py
create mode 100644 backend/communications/migrations/0040_message_mentions.py
create mode 100644 backend/communications/migrations/0041_chatreadstate_unread_mention_count.py
create mode 100644 backend/tests/api/v1/communications/test_mentions.py
create mode 100644 frontend/src/lib/dropdown-position.test.js
create mode 100644 frontend/src/lib/dropdown-position.ts
create mode 100644 frontend/src/lib/messages/mentions.test.js
create mode 100644 frontend/src/lib/messages/mentions.ts
diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py
index 317aca97..1e47ec9f 100644
--- a/backend/communications/api/serializers.py
+++ b/backend/communications/api/serializers.py
@@ -23,6 +23,7 @@ from ..models import (
ChatMembership,
ChatUserSettings,
)
+from ..mentions import build_message_mentions
from ..serialization import serialize_message, serialize_poll
from ..utils import user_can_access_chat
@@ -160,6 +161,7 @@ class ChatListSerializer(serializers.ModelSerializer):
last_message = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
+ has_unread_mention = serializers.SerializerMethodField()
participant_details = serializers.SerializerMethodField()
participant_names = serializers.SerializerMethodField()
member_ids = serializers.SerializerMethodField()
@@ -192,6 +194,7 @@ class ChatListSerializer(serializers.ModelSerializer):
# Messages & status
"last_message",
"unread_count",
+ "has_unread_mention",
"participant_details",
"participant_names",
"member_ids",
@@ -212,6 +215,12 @@ class ChatListSerializer(serializers.ModelSerializer):
return obj.my_read_state[0].unread_count
return 0
+ @extend_schema_field(serializers.BooleanField())
+ def get_has_unread_mention(self, obj):
+ if hasattr(obj, "my_read_state") and obj.my_read_state:
+ return obj.my_read_state[0].unread_mention_count > 0
+ return False
+
@extend_schema_field(ChatLastMessageSerializer)
def get_last_message(self, obj):
"""Последнее сообщение в чате (использует prefetch если доступен)"""
@@ -283,6 +292,7 @@ class ChatDetailSerializer(serializers.ModelSerializer):
last_read_message_id = serializers.SerializerMethodField()
last_message = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
+ has_unread_mention = serializers.SerializerMethodField()
context_content_type = serializers.PrimaryKeyRelatedField(
queryset=ContentType.objects.all(),
write_only=True,
@@ -332,6 +342,7 @@ class ChatDetailSerializer(serializers.ModelSerializer):
"last_read_message_id",
"last_message",
"unread_count",
+ "has_unread_mention",
]
read_only_fields = [
"created_at",
@@ -412,6 +423,12 @@ class ChatDetailSerializer(serializers.ModelSerializer):
return obj.my_read_state[0].unread_count
return 0
+ @extend_schema_field(serializers.BooleanField())
+ def get_has_unread_mention(self, obj):
+ if hasattr(obj, "my_read_state") and obj.my_read_state:
+ return obj.my_read_state[0].unread_mention_count > 0
+ return False
+
class MessageAttachmentSerializer(serializers.ModelSerializer):
"""Вложение сообщения"""
@@ -697,6 +714,7 @@ class MessageListSerializer(serializers.ModelSerializer):
fields = [
"id",
"content",
+ "mentions",
"author",
"author_name",
"author_avatar",
@@ -739,9 +757,23 @@ class MessageDetailSerializer(serializers.ModelSerializer):
class MessageCreateSerializer(serializers.ModelSerializer):
"""Сериализатор для создания/редактирования сообщений"""
+ mentioned_user_ids = serializers.ListField(
+ child=serializers.IntegerField(min_value=1),
+ write_only=True,
+ required=False,
+ allow_empty=True,
+ max_length=50,
+ )
+
class Meta:
model = Message
- fields = ["content", "chat", "reply_to", "is_forwarded"]
+ fields = [
+ "content",
+ "chat",
+ "reply_to",
+ "is_forwarded",
+ "mentioned_user_ids",
+ ]
def validate(self, attrs):
"""Валидация: сообщение должно иметь либо текст, либо вложения"""
@@ -750,7 +782,15 @@ class MessageCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""Создание сообщения с автором"""
- validated_data["author"] = self.context["request"].user
+ author = self.context["request"].user
+ mentioned_user_ids = validated_data.pop("mentioned_user_ids", [])
+ validated_data["author"] = author
+ validated_data["mentions"] = build_message_mentions(
+ validated_data["chat"],
+ author,
+ validated_data.get("content", ""),
+ mentioned_user_ids,
+ )
return super().create(validated_data)
@@ -761,6 +801,12 @@ class MessageEditSerializer(serializers.Serializer):
existing_attachment_ids = serializers.ListField(
child=serializers.IntegerField(), required=False, allow_empty=True
)
+ mentioned_user_ids = serializers.ListField(
+ child=serializers.IntegerField(min_value=1),
+ required=False,
+ allow_empty=True,
+ max_length=50,
+ )
class ReactionSerializer(serializers.Serializer):
diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py
index a2da4d29..5cb8d647 100644
--- a/backend/communications/api/viewsets.py
+++ b/backend/communications/api/viewsets.py
@@ -53,6 +53,15 @@ from ..models import (
MessageReaction,
Poll,
)
+from ..mentions import (
+ build_message_mentions,
+ filter_mentionable_users,
+ get_mentionable_users,
+ mentioned_user_ids as get_mentioned_user_ids,
+ mention_display_name,
+ parse_mentioned_user_ids,
+ update_unread_mention_counters,
+)
from ..polls import PollActionError, cast_poll_vote
from ..services import soft_delete_message
from ..utils import _coerce_ts, user_can_access_chat
@@ -350,6 +359,29 @@ class ChatViewSet(viewsets.ModelViewSet):
{"ok": True, "notifications_enabled": settings.notifications_enabled}
)
+ @action(detail=True, methods=["get"], url_path="mention-candidates")
+ def mention_candidates(self, request, pk=None):
+ """Active chat participants available for an @mention."""
+ chat = self.get_object()
+ query = str(request.query_params.get("q", "")).strip()[:100]
+ users = get_mentionable_users(chat, author=request.user).order_by(
+ "last_name", "first_name", "patronymic", "id"
+ )
+ if query:
+ users = filter_mentionable_users(users, query)
+
+ candidates = []
+ for user in users:
+ candidates.append(
+ {
+ "id": user.id,
+ "name": mention_display_name(user),
+ "email": user.email,
+ "avatar": user.avatar.url if getattr(user, "avatar", None) else None,
+ }
+ )
+ return Response({"results": candidates})
+
@action(detail=True, methods=["post"])
def leave(self, request, pk=None):
"""Покинуть чат (выход пользователя из чата)"""
@@ -638,6 +670,7 @@ class ChatViewSet(viewsets.ModelViewSet):
defaults={
"last_read_message": last_message,
"unread_count": 0, # Прочитали → обнуляем
+ "unread_mention_count": 0,
},
)
@@ -674,8 +707,14 @@ class ChatViewSet(viewsets.ModelViewSet):
read_state.last_read_message = last_message
read_state.unread_count = 0 # Прочитали → обнуляем
+ read_state.unread_mention_count = 0
read_state.save(
- update_fields=["last_read_message", "unread_count", "updated_at"]
+ update_fields=[
+ "last_read_message",
+ "unread_count",
+ "unread_mention_count",
+ "updated_at",
+ ]
)
logger.info(
@@ -1344,6 +1383,9 @@ class MessageViewSet(viewsets.ModelViewSet):
chat_id = request.data.get("chat_id")
content = request.data.get("content", "").strip()
reply_to_id = request.data.get("reply_to") or request.data.get("reply_to_id")
+ mentioned_user_ids = parse_mentioned_user_ids(
+ request.data.get("mentioned_user_ids")
+ )
# Валидация чата
try:
@@ -1389,6 +1431,12 @@ class MessageViewSet(viewsets.ModelViewSet):
chat=chat,
author=request.user,
content=content,
+ mentions=build_message_mentions(
+ chat,
+ request.user,
+ content,
+ mentioned_user_ids,
+ ),
reply_to=reply_to_message,
has_attachments=len(files) > 0,
)
@@ -1499,6 +1547,19 @@ class MessageViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
new_content = serializer.validated_data.get("content", "").strip()
+ previous_mention_ids = get_mentioned_user_ids(instance.mentions)
+ requested_mention_ids = serializer.validated_data.get(
+ "mentioned_user_ids",
+ list(previous_mention_ids),
+ )
+ next_mentions = build_message_mentions(
+ instance.chat,
+ request.user,
+ new_content,
+ requested_mention_ids,
+ )
+ added_mention_ids = get_mentioned_user_ids(next_mentions) - previous_mention_ids
+ removed_mention_ids = previous_mention_ids - get_mentioned_user_ids(next_mentions)
existing_attachment_ids = serializer.validated_data.get(
"existing_attachment_ids"
)
@@ -1523,6 +1584,7 @@ class MessageViewSet(viewsets.ModelViewSet):
)
instance.content = new_content
+ instance.mentions = next_mentions
instance.is_edited = True
instance.edited_at = timezone.now()
@@ -1570,6 +1632,17 @@ class MessageViewSet(viewsets.ModelViewSet):
instance.save()
+ update_unread_mention_counters(
+ instance,
+ added_user_ids=added_mention_ids,
+ removed_user_ids=removed_mention_ids,
+ )
+
+ if added_mention_ids:
+ from communications.notifications.handlers import notify_message_mentions
+
+ notify_message_mentions(instance, recipient_ids=added_mention_ids)
+
# ВАЖНО: Очищаем кэш attachments перед подсчетом
instance.refresh_from_db()
diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py
index 8028c9fc..dc97df5f 100644
--- a/backend/communications/consumers.py
+++ b/backend/communications/consumers.py
@@ -27,6 +27,12 @@ from .models import (
MessageReaction,
Poll,
)
+from .mentions import (
+ build_message_mentions,
+ mentioned_user_ids,
+ parse_mentioned_user_ids,
+ update_unread_mention_counters,
+)
from .polls import PollActionError, cast_poll_vote
from .serialization import serialize_message, serialize_poll
from .services import soft_delete_message
@@ -380,6 +386,11 @@ class ChatConsumerMixin:
field="content",
max_length=20_000,
)
+ mention_user_ids = (
+ parse_mentioned_user_ids(content.get("mentioned_user_ids"))
+ if "mentioned_user_ids" in content
+ else None
+ )
chat = await self._get_chat(self.active_chat_id)
if not chat or not await self._user_can_access(chat, self.user):
@@ -401,7 +412,7 @@ class ChatConsumerMixin:
return
# Создаем сообщение
- msg = await self._create_message(chat, self.user, text)
+ msg = await self._create_message(chat, self.user, text, mention_user_ids)
if msg:
# Сериализуем и отправляем всем в группе
@@ -427,6 +438,11 @@ class ChatConsumerMixin:
field="content",
max_length=20_000,
)
+ mention_user_ids = (
+ parse_mentioned_user_ids(content.get("mentioned_user_ids"))
+ if "mentioned_user_ids" in content
+ else None
+ )
msg = await self._get_message(message_id)
if (
@@ -439,7 +455,7 @@ class ChatConsumerMixin:
return
# Обновляем сообщение
- await self._update_message(msg, new_content)
+ await self._update_message(msg, new_content, mention_user_ids)
# Сериализуем и отправляем
msg_data = await database_sync_to_async(serialize_message)(msg)
@@ -769,10 +785,20 @@ class ChatConsumerMixin:
)
@database_sync_to_async
- def _create_message(self, chat, user, content):
+ def _create_message(self, chat, user, content, mention_user_ids=None):
"""Создать новое сообщение"""
try:
- msg = Message.objects.create(chat=chat, author=user, content=content)
+ msg = Message.objects.create(
+ chat=chat,
+ author=user,
+ content=content,
+ mentions=build_message_mentions(
+ chat,
+ user,
+ content,
+ mention_user_ids or [],
+ ),
+ )
return msg
except Exception as e:
logger.exception(
@@ -787,12 +813,36 @@ class ChatConsumerMixin:
return None
@database_sync_to_async
- def _update_message(self, msg, new_content):
+ def _update_message(self, msg, new_content, mention_user_ids=None):
"""Обновить содержимое сообщения"""
+ previous_mention_ids = mentioned_user_ids(msg.mentions)
+ requested_mention_ids = (
+ mention_user_ids
+ if mention_user_ids is not None
+ else list(previous_mention_ids)
+ )
+ next_mentions = build_message_mentions(
+ msg.chat,
+ msg.author,
+ new_content,
+ requested_mention_ids,
+ )
+ added_mention_ids = mentioned_user_ids(next_mentions) - previous_mention_ids
+ removed_mention_ids = previous_mention_ids - mentioned_user_ids(next_mentions)
msg.content = new_content
+ msg.mentions = next_mentions
msg.is_edited = True
msg.edited_at = timezone.now()
- msg.save(update_fields=["content", "is_edited", "edited_at"])
+ msg.save(update_fields=["content", "mentions", "is_edited", "edited_at"])
+ update_unread_mention_counters(
+ msg,
+ added_user_ids=added_mention_ids,
+ removed_user_ids=removed_mention_ids,
+ )
+ if added_mention_ids:
+ from communications.notifications.handlers import notify_message_mentions
+
+ notify_message_mentions(msg, recipient_ids=added_mention_ids)
@database_sync_to_async
def _soft_delete_message(self, msg):
diff --git a/backend/communications/mentions.py b/backend/communications/mentions.py
new file mode 100644
index 00000000..27d2fa2e
--- /dev/null
+++ b/backend/communications/mentions.py
@@ -0,0 +1,194 @@
+import json
+import re
+import unicodedata
+from collections.abc import Iterable
+
+from django.contrib.auth import get_user_model
+from django.db.models import F, Q, QuerySet
+
+User = get_user_model()
+
+MAX_MENTIONS_PER_MESSAGE = 50
+
+
+def mention_display_name(user) -> str:
+ full_name = " ".join(
+ part.strip()
+ for part in (user.last_name, user.first_name, user.patronymic)
+ if part and part.strip()
+ )
+ return (full_name or user.username or user.email or str(user.pk)).strip()
+
+
+def _normalize_search_value(value) -> str:
+ return unicodedata.normalize("NFKC", str(value or "")).casefold()
+
+
+def filter_mentionable_users(users: Iterable, query: str) -> list:
+ """Match every query token against any employee identity field."""
+ tokens = [
+ _normalize_search_value(token)
+ for token in query.split()
+ if token.strip()
+ ]
+ if not tokens:
+ return list(users)
+
+ matches = []
+ for user in users:
+ fields = (
+ _normalize_search_value(user.last_name),
+ _normalize_search_value(user.first_name),
+ _normalize_search_value(user.patronymic),
+ _normalize_search_value(user.email),
+ _normalize_search_value(user.username),
+ )
+ if all(any(token in field for field in fields) for token in tokens):
+ matches.append(user)
+ return matches
+
+
+def get_mentionable_users(chat, *, author=None) -> QuerySet:
+ """Return active users who can see the chat, including legacy memberships."""
+ if chat.type == "global" or chat.include_all_users:
+ users = User.objects.filter(is_active=True)
+ else:
+ users = User.objects.filter(
+ Q(chat_memberships__chat=chat, chat_memberships__is_active=True)
+ | Q(chats=chat)
+ ).filter(is_active=True).distinct()
+
+ if author is not None:
+ users = users.exclude(pk=author.pk)
+ return users
+
+
+def parse_mentioned_user_ids(raw_value) -> list[int]:
+ if raw_value in (None, ""):
+ return []
+
+ values = raw_value
+ if isinstance(raw_value, str):
+ try:
+ values = json.loads(raw_value)
+ except json.JSONDecodeError:
+ values = raw_value.split(",")
+ if not isinstance(values, Iterable) or isinstance(values, (str, bytes, dict)):
+ values = [values]
+
+ result = []
+ seen = set()
+ for value in values:
+ try:
+ user_id = int(value)
+ except (TypeError, ValueError):
+ continue
+ if user_id <= 0 or user_id in seen:
+ continue
+ seen.add(user_id)
+ result.append(user_id)
+ if len(result) >= MAX_MENTIONS_PER_MESSAGE:
+ break
+ return result
+
+
+def build_message_mentions(chat, author, content: str, user_ids) -> list[dict]:
+ """Validate selected users and resolve their visible mention ranges."""
+ normalized_ids = parse_mentioned_user_ids(user_ids)
+ if not content or not normalized_ids:
+ return []
+
+ users_by_id = {
+ user.id: user
+ for user in get_mentionable_users(chat, author=author).filter(
+ id__in=normalized_ids
+ )
+ }
+ mentions = []
+ occupied_ranges: list[tuple[int, int]] = []
+
+ for user_id in normalized_ids:
+ user = users_by_id.get(user_id)
+ if user is None:
+ continue
+ label = mention_display_name(user)
+ if not label:
+ continue
+ pattern = re.compile(
+ rf"(? used_start for used_start, used_end in occupied_ranges):
+ continue
+ occupied_ranges.append((start, end))
+ mentions.append(
+ {
+ "user_id": user.id,
+ "name": label,
+ "start": start,
+ "end": end,
+ }
+ )
+
+ return sorted(mentions, key=lambda mention: mention["start"])
+
+
+def mentioned_user_ids(mentions) -> set[int]:
+ result = set()
+ for mention in mentions or []:
+ try:
+ result.add(int(mention.get("user_id")))
+ except (AttributeError, TypeError, ValueError):
+ continue
+ return result
+
+
+def update_unread_mention_counters(
+ message,
+ *,
+ added_user_ids=(),
+ removed_user_ids=(),
+) -> None:
+ """Keep per-chat mention badges correct when a message is edited."""
+ from .models import ChatReadState
+
+ added_ids = set(parse_mentioned_user_ids(added_user_ids))
+ removed_ids = set(parse_mentioned_user_ids(removed_user_ids)) - added_ids
+
+ if removed_ids:
+ ChatReadState.objects.filter(
+ chat_id=message.chat_id,
+ user_id__in=removed_ids,
+ unread_mention_count__gt=0,
+ ).update(unread_mention_count=F("unread_mention_count") - 1)
+
+ if not added_ids:
+ return
+
+ existing_ids = set(
+ ChatReadState.objects.filter(
+ chat_id=message.chat_id,
+ user_id__in=added_ids,
+ ).values_list("user_id", flat=True)
+ )
+ if existing_ids:
+ ChatReadState.objects.filter(
+ chat_id=message.chat_id,
+ user_id__in=existing_ids,
+ ).update(unread_mention_count=F("unread_mention_count") + 1)
+
+ missing_ids = added_ids - existing_ids
+ if missing_ids:
+ ChatReadState.objects.bulk_create(
+ [
+ ChatReadState(
+ chat_id=message.chat_id,
+ user_id=user_id,
+ unread_mention_count=1,
+ )
+ for user_id in missing_ids
+ ],
+ ignore_conflicts=True,
+ )
diff --git a/backend/communications/migrations/0040_message_mentions.py b/backend/communications/migrations/0040_message_mentions.py
new file mode 100644
index 00000000..49835655
--- /dev/null
+++ b/backend/communications/migrations/0040_message_mentions.py
@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("communications", "0039_alter_chat_context_object_id_bigint"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="message",
+ name="mentions",
+ field=models.JSONField(
+ blank=True,
+ default=list,
+ help_text="Проверенные ссылки на пользователей и диапазоны в тексте сообщения",
+ verbose_name="Упоминания",
+ ),
+ ),
+ ]
diff --git a/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py b/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py
new file mode 100644
index 00000000..51784167
--- /dev/null
+++ b/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py
@@ -0,0 +1,62 @@
+from django.db import migrations, models
+
+
+def backfill_unread_mentions(apps, schema_editor):
+ ChatReadState = apps.get_model("communications", "ChatReadState")
+ Message = apps.get_model("communications", "Message")
+
+ states = list(ChatReadState.objects.all())
+ states_by_chat_and_user = {
+ (state.chat_id, state.user_id): state for state in states
+ }
+
+ for message in Message.objects.filter(is_deleted=False).exclude(mentions=[]).iterator():
+ seen_user_ids = set()
+ for mention in message.mentions or []:
+ try:
+ user_id = int(mention.get("user_id"))
+ except (AttributeError, TypeError, ValueError):
+ continue
+ if user_id in seen_user_ids or user_id == message.author_id:
+ continue
+ seen_user_ids.add(user_id)
+ state = states_by_chat_and_user.get((message.chat_id, user_id))
+ if state is None:
+ continue
+ if state.last_read_message_id and state.last_read_message_id >= message.id:
+ continue
+ state.unread_mention_count += 1
+
+ if states:
+ ChatReadState.objects.bulk_update(states, ["unread_mention_count"])
+
+
+def clear_unread_mentions(apps, schema_editor):
+ ChatReadState = apps.get_model("communications", "ChatReadState")
+ ChatReadState.objects.update(unread_mention_count=0)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("communications", "0040_message_mentions"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="chatreadstate",
+ name="unread_mention_count",
+ field=models.PositiveIntegerField(
+ default=0,
+ help_text="Кешированное количество непрочитанных сообщений с @-упоминанием пользователя.",
+ verbose_name="Непрочитанных упоминаний",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="chatreadstate",
+ index=models.Index(
+ fields=["user", "unread_mention_count"],
+ name="communicati_user_id_0c9ce2_idx",
+ ),
+ ),
+ migrations.RunPython(backfill_unread_mentions, clear_unread_mentions),
+ ]
diff --git a/backend/communications/models.py b/backend/communications/models.py
index d346383f..21097ddd 100644
--- a/backend/communications/models.py
+++ b/backend/communications/models.py
@@ -49,6 +49,11 @@ class ChatReadState(models.Model):
"Обновляется при новых сообщениях и прочтении."
),
)
+ unread_mention_count = models.PositiveIntegerField(
+ default=0,
+ verbose_name="Непрочитанных упоминаний",
+ help_text="Кешированное количество непрочитанных сообщений с @-упоминанием пользователя.",
+ )
# Статус набора текста
is_typing = models.BooleanField(default=False, verbose_name="Набирает сообщение")
@@ -72,6 +77,7 @@ class ChatReadState(models.Model):
models.Index(fields=["last_read_message"]),
# Для быстрых запросов непрочитанных
models.Index(fields=["user", "unread_count"]),
+ models.Index(fields=["user", "unread_mention_count"]),
]
def __str__(self):
@@ -257,7 +263,11 @@ class Chat(models.Model):
read_state, created = ChatReadState.objects.get_or_create(
chat=self,
user=user,
- defaults={"last_read_message": last_message, "unread_count": 0},
+ defaults={
+ "last_read_message": last_message,
+ "unread_count": 0,
+ "unread_mention_count": 0,
+ },
)
if not created:
@@ -267,10 +277,12 @@ class Chat(models.Model):
read_state.last_read_message = last_message
read_state.unread_count = 0
+ read_state.unread_mention_count = 0
read_state.save(
update_fields=[
"last_read_message",
"unread_count",
+ "unread_mention_count",
"updated_at",
]
)
@@ -440,6 +452,12 @@ class Message(models.Model):
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="Автор"
)
content = models.TextField("Текст сообщения")
+ mentions = models.JSONField(
+ default=list,
+ blank=True,
+ verbose_name="Упоминания",
+ help_text="Проверенные ссылки на пользователей и диапазоны в тексте сообщения",
+ )
created_at = models.DateTimeField(
auto_now_add=True, verbose_name="Время отправки", db_index=True
)
diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py
index fd5d69c6..8384dd59 100644
--- a/backend/communications/notifications/handlers.py
+++ b/backend/communications/notifications/handlers.py
@@ -23,11 +23,11 @@ except ImportError:
notify = None
from ..models import ChatUserSettings
+from ..mentions import get_mentionable_users, mentioned_user_ids
from .config import (
NotificationVerbs,
MessageTemplates,
ActionURLs,
- extract_mentions,
truncate_message,
get_chat_name,
)
@@ -74,6 +74,53 @@ def get_users_with_notifications_enabled(chat, users) -> dict[int, bool]:
return {uid: settings_dict.get(uid, True) for uid in user_ids}
+def notify_message_mentions(
+ message,
+ *,
+ recipient_ids=None,
+ participants_list=None,
+) -> set[int]:
+ """Send one high-priority notification to each validated mentioned user."""
+ chat = message.chat
+ author = message.author
+ selected_ids = mentioned_user_ids(message.mentions)
+ if recipient_ids is not None:
+ selected_ids.intersection_update({int(user_id) for user_id in recipient_ids})
+ selected_ids.discard(author.id)
+ if not selected_ids:
+ return set()
+
+ if participants_list is None:
+ participants_list = list(get_mentionable_users(chat, author=author))
+ participant_by_id = {user.id: user for user in participants_list}
+ recipients = [
+ participant_by_id[user_id]
+ for user_id in selected_ids
+ if user_id in participant_by_id
+ ]
+ author_name = author.get_full_name() or author.username
+ notified_user_ids = set()
+ for recipient in recipients:
+ _send_notification(
+ sender=author,
+ recipient=recipient,
+ verb=NotificationVerbs.MENTION,
+ action_object=message,
+ description=truncate_message(message.content, 100),
+ action_url=ActionURLs.message_detail(chat.id, message.id),
+ data={
+ "title": MessageTemplates.mention(author_name),
+ "chat_id": chat.id,
+ "chat_name": get_chat_name(chat),
+ "message_id": message.id,
+ "author_id": author.id,
+ "mentioned": True,
+ },
+ )
+ notified_user_ids.add(recipient.id)
+ return notified_user_ids
+
+
def notify_new_message(message):
"""
Отправляет уведомления о новом сообщении в чате.
@@ -96,14 +143,12 @@ def notify_new_message(message):
# Специальная обработка для чатов комментариев
if chat.type == "comments":
- _notify_comment(message)
+ mentioned_recipient_ids = notify_message_mentions(message)
+ _notify_comment(message, excluded_recipient_ids=mentioned_recipient_ids)
return
# Получаем всех участников чата кроме автора
- if chat.type in ["announcement", "channel", "global"]:
- participants = chat.get_participants().exclude(id=author.id)
- else:
- participants = chat.participants.exclude(id=author.id)
+ participants = get_mentionable_users(chat, author=author)
# ОПТИМИЗАЦИЯ: Загружаем настройки уведомлений ОДНИМ запросом
participants_list = list(participants)
@@ -117,34 +162,13 @@ def notify_new_message(message):
# Отслеживаем, кому уже отправили уведомления
notified_user_ids = set()
- # 1. УПОМИНАНИЯ (@username)
- mentioned_users = extract_mentions(content)
author_name = author.get_full_name() or author.username
-
- if mentioned_users:
- for email in mentioned_users:
- try:
- user = User.objects.get(email=email)
- if user in participants_list and user.id != author.id:
- if notification_settings.get(user.id, True):
- _send_notification(
- sender=author,
- recipient=user,
- verb=NotificationVerbs.MENTION,
- action_object=message,
- description=truncate_message(content, 100),
- action_url=ActionURLs.chat_detail(chat.id),
- data={
- "title": MessageTemplates.mention(author_name),
- "chat_id": chat.id,
- "chat_name": get_chat_name(chat),
- "message_id": message.id,
- "author_id": author.id,
- },
- )
- notified_user_ids.add(user.id)
- except User.DoesNotExist:
- continue
+ notified_user_ids.update(
+ notify_message_mentions(
+ message,
+ participants_list=participants_list,
+ )
+ )
# 2. ОТВЕТЫ НА СООБЩЕНИЯ
if message.reply_to and message.reply_to.author_id != author.id:
@@ -214,7 +238,7 @@ def notify_new_message(message):
)
-def _notify_comment(message):
+def _notify_comment(message, excluded_recipient_ids=None):
"""
Отправляет уведомления о новом комментарии к объекту.
@@ -319,6 +343,7 @@ def _notify_comment(message):
recipient_ids.add(message.reply_to.author_id)
recipient_ids.discard(author.id)
+ recipient_ids.difference_update(excluded_recipient_ids or set())
if not recipient_ids:
return
diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py
index 0ad014b7..3927fb43 100644
--- a/backend/communications/serialization.py
+++ b/backend/communications/serialization.py
@@ -258,6 +258,7 @@ def serialize_message(m, user=None, include_linked_tasks=False) -> dict:
data = {
"id": m.id,
"content": m.content,
+ "mentions": m.mentions or [],
"author_id": author.id if author else None,
"author_name": author_name,
"author_url": _get_author_url(author),
diff --git a/backend/communications/services/message_deletion.py b/backend/communications/services/message_deletion.py
index 6aa2d04e..1eee82ee 100644
--- a/backend/communications/services/message_deletion.py
+++ b/backend/communications/services/message_deletion.py
@@ -5,6 +5,7 @@ from django.db.models import F, Q
from django.utils import timezone
from communications.models import ChatReadState, Message
+from communications.mentions import mentioned_user_ids
def soft_delete_message(
@@ -29,6 +30,13 @@ def soft_delete_message(
)
unread_states.update(unread_count=F("unread_count") - 1)
+ mention_recipient_ids = mentioned_user_ids(locked_message.mentions)
+ if mention_recipient_ids:
+ unread_states.filter(
+ user_id__in=mention_recipient_ids,
+ unread_mention_count__gt=0,
+ ).update(unread_mention_count=F("unread_mention_count") - 1)
+
locked_message.is_deleted = True
locked_message.deleted_at = timezone.now()
locked_message.deleted_by = deleted_by
diff --git a/backend/communications/signals.py b/backend/communications/signals.py
index 46959b0e..aaba1c5f 100644
--- a/backend/communications/signals.py
+++ b/backend/communications/signals.py
@@ -164,11 +164,13 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
return
from communications.models import ChatReadState
+ from communications.mentions import mentioned_user_ids
try:
# Получаем всех участников чата
chat = instance.chat
participants = chat.get_participants().exclude(id=instance.author_id)
+ mentioned_ids = mentioned_user_ids(instance.mentions)
# Инкрементируем счетчик для существующих ChatReadState
# Только для тех, кто еще не прочитал это сообщение
@@ -181,14 +183,26 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
.update(unread_count=F("unread_count") + 1)
)
+ updated_mention_count = 0
+ if mentioned_ids:
+ updated_mention_count = (
+ ChatReadState.objects.filter(
+ chat=chat,
+ user__in=participants,
+ user_id__in=mentioned_ids,
+ )
+ .exclude(last_read_message_id__gte=instance.id)
+ .update(unread_mention_count=F("unread_mention_count") + 1)
+ )
+
# Создаем ChatReadState для участников, у которых его еще нет
existing_users = ChatReadState.objects.filter(
chat=chat, user__in=participants
).values_list("user_id", flat=True)
- new_users = participants.exclude(id__in=existing_users)
+ new_users = list(participants.exclude(id__in=existing_users))
- if new_users.exists():
+ if new_users:
# Создаем ChatReadState с unread_count=1 для новых участников
ChatReadState.objects.bulk_create(
[
@@ -197,6 +211,7 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
user=user,
last_read_message=None,
unread_count=1,
+ unread_mention_count=1 if user.id in mentioned_ids else 0,
)
for user in new_users
],
@@ -209,7 +224,7 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
"event": "chat.read_states.created",
"chat_id": chat.id,
"message_id": instance.id,
- "state_count": new_users.count(),
+ "state_count": len(new_users),
},
)
@@ -220,6 +235,7 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
"chat_id": chat.id,
"message_id": instance.id,
"state_count": updated_count,
+ "mention_state_count": updated_mention_count,
},
)
diff --git a/backend/docs/architecture/websocket-asyncapi.yaml b/backend/docs/architecture/websocket-asyncapi.yaml
index df28ca7f..0092a088 100644
--- a/backend/docs/architecture/websocket-asyncapi.yaml
+++ b/backend/docs/architecture/websocket-asyncapi.yaml
@@ -123,6 +123,10 @@ components:
content:
type: string
nullable: true
+ mentions:
+ type: array
+ items:
+ $ref: '#/components/schemas/MessageMention'
chat:
type: integer
author:
@@ -133,6 +137,20 @@ components:
required:
- id
additionalProperties: true
+ MessageMention:
+ type: object
+ required: [user_id, name, start, end]
+ properties:
+ user_id:
+ $ref: '#/components/schemas/UserId'
+ name:
+ type: string
+ start:
+ type: integer
+ minimum: 0
+ end:
+ type: integer
+ minimum: 1
NotificationPayload:
type: object
description: Сериализованное уведомление пользователя.
@@ -236,6 +254,12 @@ components:
type: string
text:
type: string
+ mentioned_user_ids:
+ type: array
+ maxItems: 50
+ uniqueItems: true
+ items:
+ $ref: '#/components/schemas/UserId'
EditMessageAction:
name: EditMessageAction
title: Редактировать сообщение
@@ -250,6 +274,12 @@ components:
$ref: '#/components/schemas/MessageId'
content:
type: string
+ mentioned_user_ids:
+ type: array
+ maxItems: 50
+ uniqueItems: true
+ items:
+ $ref: '#/components/schemas/UserId'
DeleteMessageAction:
name: DeleteMessageAction
title: Удалить сообщение
diff --git a/backend/tests/api/v1/communications/test_mentions.py b/backend/tests/api/v1/communications/test_mentions.py
new file mode 100644
index 00000000..6f79db2a
--- /dev/null
+++ b/backend/tests/api/v1/communications/test_mentions.py
@@ -0,0 +1,295 @@
+from unittest.mock import patch
+
+import pytest
+from communications.mentions import build_message_mentions, mention_display_name
+from communications.models import Chat, ChatReadState, ChatUserSettings, Message
+from django.contrib.auth import get_user_model
+from rest_framework.test import APIClient
+
+
+User = get_user_model()
+pytestmark = pytest.mark.django_db
+
+
+def create_user(index: int):
+ return User.objects.create_user(
+ email=f"mention-user-{index}@test.com",
+ password="testpass123",
+ first_name="Mention",
+ last_name=f"User {index}",
+ username=f"mention-user-{index}",
+ phone_number=f"+799900001{index:02d}",
+ is_active=True,
+ send_activation_email=False,
+ )
+
+
+@pytest.fixture
+def mention_users():
+ return create_user(1), create_user(2), create_user(3)
+
+
+@pytest.fixture
+def private_chat(mention_users):
+ author, recipient, _outsider = mention_users
+ chat = Chat.objects.create(type="private", created_by=author)
+ chat.participants.add(author, recipient)
+ return chat
+
+
+@pytest.fixture
+def auth_client(mention_users):
+ client = APIClient()
+ client.force_authenticate(user=mention_users[0])
+ return client
+
+
+def test_build_mentions_accepts_participant_and_rejects_outsider(
+ private_chat,
+ mention_users,
+):
+ author, recipient, outsider = mention_users
+ recipient_name = mention_display_name(recipient)
+ outsider_name = mention_display_name(outsider)
+ content = f"Привет @{recipient_name} и @{outsider_name}"
+
+ mentions = build_message_mentions(
+ private_chat,
+ author,
+ content,
+ [recipient.id, outsider.id],
+ )
+
+ assert mentions == [
+ {
+ "user_id": recipient.id,
+ "name": recipient_name,
+ "start": 7,
+ "end": 7 + len(f"@{recipient_name}"),
+ }
+ ]
+
+
+def test_private_chat_candidates_only_include_counterpart(
+ auth_client,
+ private_chat,
+ mention_users,
+):
+ _author, recipient, outsider = mention_users
+
+ response = auth_client.get(
+ f"/api/v1/communications/chats/{private_chat.id}/mention-candidates/"
+ )
+
+ assert response.status_code == 200
+ assert [candidate["id"] for candidate in response.data["results"]] == [
+ recipient.id
+ ]
+ assert outsider.id not in {
+ candidate["id"] for candidate in response.data["results"]
+ }
+
+
+def test_global_chat_candidates_are_not_truncated(auth_client, mention_users):
+ author, _recipient, _outsider = mention_users
+ extra_users = [create_user(index) for index in range(10, 35)]
+ chat = Chat.objects.create(
+ type="global",
+ name="Global mentions",
+ created_by=author,
+ include_all_users=True,
+ )
+
+ response = auth_client.get(
+ f"/api/v1/communications/chats/{chat.id}/mention-candidates/"
+ )
+
+ assert response.status_code == 200
+ result_ids = {candidate["id"] for candidate in response.data["results"]}
+ assert len(result_ids) == len(mention_users) + len(extra_users) - 1
+ assert {user.id for user in extra_users}.issubset(result_ids)
+
+
+@pytest.mark.parametrize(
+ "query",
+ [
+ "Иванов",
+ "Петр",
+ "Сергеевич",
+ "Петр Иванов",
+ "Сергеевич Иванов Петр",
+ "иванов петр",
+ "сергеевич",
+ "ivanov.petrov",
+ "petrov-ldap",
+ ],
+)
+def test_candidates_search_all_identity_fields(
+ auth_client,
+ mention_users,
+ query,
+):
+ author, _recipient, _outsider = mention_users
+ employee = User.objects.create_user(
+ email="ivanov.petrov@company.test",
+ password="testpass123",
+ first_name="Петр",
+ last_name="Иванов",
+ patronymic="Сергеевич",
+ username="petrov-ldap",
+ phone_number="+79990000900",
+ is_active=True,
+ send_activation_email=False,
+ )
+ chat = Chat.objects.create(
+ type="global",
+ name="Global search",
+ created_by=author,
+ include_all_users=True,
+ )
+
+ response = auth_client.get(
+ f"/api/v1/communications/chats/{chat.id}/mention-candidates/",
+ {"q": query},
+ )
+
+ assert response.status_code == 200
+ candidate = next(
+ item for item in response.data["results"] if item["id"] == employee.id
+ )
+ assert candidate["name"] == "Иванов Петр Сергеевич"
+ assert candidate["email"] == employee.email
+
+
+def test_upload_persists_structured_mention_and_sends_single_notification(
+ auth_client,
+ private_chat,
+ mention_users,
+):
+ author, recipient, _outsider = mention_users
+ recipient_name = mention_display_name(recipient)
+ content = f"Привет @{recipient_name}"
+
+ with patch(
+ "communications.notifications.handlers._send_notification"
+ ) as send_notification:
+ response = auth_client.post(
+ "/api/v1/communications/messages/upload/",
+ {
+ "chat_id": private_chat.id,
+ "content": content,
+ "mentioned_user_ids": f"[{recipient.id}]",
+ },
+ format="multipart",
+ )
+
+ assert response.status_code == 201
+ message = Message.objects.get(pk=response.data["message"]["id"])
+ assert message.mentions == [
+ {
+ "user_id": recipient.id,
+ "name": recipient_name,
+ "start": 7,
+ "end": len(content),
+ }
+ ]
+ assert response.data["message"]["mentions"] == message.mentions
+ send_notification.assert_called_once()
+ notification = send_notification.call_args.kwargs
+ assert notification["sender"] == author
+ assert notification["recipient"] == recipient
+ assert notification["verb"] == "chat_mention"
+
+
+def test_mention_bypasses_chat_mute_and_badge_clears_when_read(
+ auth_client,
+ private_chat,
+ mention_users,
+):
+ _author, recipient, _outsider = mention_users
+ recipient_name = mention_display_name(recipient)
+ ChatUserSettings.objects.create(
+ chat=private_chat,
+ user=recipient,
+ notifications_enabled=False,
+ )
+
+ with patch(
+ "communications.notifications.handlers._send_notification"
+ ) as send_notification:
+ response = auth_client.post(
+ "/api/v1/communications/messages/upload/",
+ {
+ "chat_id": private_chat.id,
+ "content": f"Важно для @{recipient_name}",
+ "mentioned_user_ids": f"[{recipient.id}]",
+ },
+ format="multipart",
+ )
+
+ assert response.status_code == 201
+ message_id = response.data["message"]["id"]
+ send_notification.assert_called_once()
+ assert send_notification.call_args.kwargs["recipient"] == recipient
+ assert send_notification.call_args.kwargs["verb"] == "chat_mention"
+
+ read_state = ChatReadState.objects.get(chat=private_chat, user=recipient)
+ assert read_state.unread_mention_count == 1
+
+ recipient_client = APIClient()
+ recipient_client.force_authenticate(user=recipient)
+ chat_response = recipient_client.get(
+ f"/api/v1/communications/chats/{private_chat.id}/"
+ )
+ assert chat_response.status_code == 200
+ assert chat_response.data["notifications_enabled"] is False
+ assert chat_response.data["has_unread_mention"] is True
+
+ read_response = recipient_client.post(
+ f"/api/v1/communications/chats/{private_chat.id}/mark-read/",
+ {"message_id": message_id},
+ format="json",
+ )
+ assert read_response.status_code == 200
+ read_state.refresh_from_db()
+ assert read_state.unread_mention_count == 0
+
+ refreshed_chat = recipient_client.get(
+ f"/api/v1/communications/chats/{private_chat.id}/"
+ )
+ assert refreshed_chat.data["has_unread_mention"] is False
+
+
+def test_edit_recalculates_and_removes_deleted_mention(
+ auth_client,
+ private_chat,
+ mention_users,
+):
+ author, recipient, _outsider = mention_users
+ message = Message.objects.create(
+ chat=private_chat,
+ author=author,
+ content=f"Привет @{mention_display_name(recipient)}",
+ mentions=build_message_mentions(
+ private_chat,
+ author,
+ f"Привет @{mention_display_name(recipient)}",
+ [recipient.id],
+ ),
+ )
+
+ response = auth_client.patch(
+ f"/api/v1/communications/messages/{message.id}/",
+ {
+ "content": "Привет без обращения",
+ "mentioned_user_ids": [recipient.id],
+ },
+ format="json",
+ )
+
+ assert response.status_code == 200
+ message.refresh_from_db()
+ assert message.mentions == []
+ assert response.data["mentions"] == []
+ read_state = ChatReadState.objects.get(chat=private_chat, user=recipient)
+ assert read_state.unread_mention_count == 0
diff --git a/docs/guides/API.md b/docs/guides/API.md
index ad4ecc0d..102801b4 100644
--- a/docs/guides/API.md
+++ b/docs/guides/API.md
@@ -145,6 +145,20 @@ Router публикует:
Кроме CRUD доступны actions для участников, ролей, поиска, истории, отметки прочитанного, загрузок, реакций, пересылки, bulk delete и голосований. Их точные URL и payload смотрите в Swagger: они формируются из `backend/communications/api/viewsets.py`.
+### Упоминания в сообщениях
+
+Доступные для `@` участники запрашиваются с учётом object-level доступа к чату:
+
+```text
+GET /api/v1/communications/chats/{chat_id}/mention-candidates/?q=Ива
+```
+
+Ответ содержит `results` с полями `id`, `name`, `email`, `avatar`. Автор запроса исключён; в личном чате возвращается только собеседник, в групповом — активные участники, в глобальном — активные пользователи. Параметр `q` ищет каждый введённый фрагмент по фамилии, имени, отчеству, email и LDAP-логину, поэтому составные ФИО можно вводить в любом порядке.
+
+При создании multipart-сообщения или `PATCH` передавайте `mentioned_user_ids` — JSON-массив выбранных ID. Сервер повторно проверяет доступ участников и наличие видимого `@Полное имя` в `content`; подставить посторонний ID нельзя. В ответе сообщения поле `mentions` содержит проверенные `{user_id, name, start, end}` для безопасного выделения текста. Максимум — 50 выбранных пользователей на сообщение.
+
+Список и detail чата возвращают `has_unread_mention`. Значение остаётся `true` после перезагрузки и сбрасывается вместе с read marker; frontend показывает его отдельным значком `@` на аватаре чата. Упоминание создаёт `chat_mention` даже при выключенных уведомлениях конкретного чата, не создавая второе обычное уведомление.
+
Для отметки прочитанного используйте HTTP action:
```text
diff --git a/docs/guides/NOTIFICATIONS.md b/docs/guides/NOTIFICATIONS.md
index d2a1fb75..dfc2b799 100644
--- a/docs/guides/NOTIFICATIONS.md
+++ b/docs/guides/NOTIFICATIONS.md
@@ -39,6 +39,8 @@ notify.send(
Не создавайте уведомления пользователю о его собственном действии, если продуктовый сценарий явно этого не требует. `action_url` должен быть внутренним разрешённым маршрутом frontend, а `data` — JSON-serializable.
+Для сообщений verb `chat_mention` создаётся только по проверенному структурированному полю `Message.mentions`, а не по произвольному тексту после `@`. Получатель упоминания исключается из обычного `chat_new_message` или `chat_reply` для этого же сообщения, поэтому одно действие не создаёт два уведомления. Персональное упоминание обходит mute конкретного чата (`ChatUserSettings.notifications_enabled=false`), но общие настройки канала, DND и системное разрешение Web Push продолжают применяться.
+
## Поток доставки
1. `notify.send` создаёт `Notification`.
@@ -177,4 +179,3 @@ pytest tests/api/v1/notifications tests/realtime -q
- Уведомление дублируется: найдите повторный `notify.send`; UI deduplication не должна маскировать backend double-create.
- Unread count устарел: операции read должны идти через API/model methods, которые инвалидируют cache и отправляют realtime state event.
- Email не приходит: проверьте frequency, DND, SMTP и поле `emailed`.
-
diff --git a/docs/guides/WEBSOCKET.md b/docs/guides/WEBSOCKET.md
index 726ea53f..0098152d 100644
--- a/docs/guides/WEBSOCKET.md
+++ b/docs/guides/WEBSOCKET.md
@@ -68,8 +68,8 @@ ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Ес
| --- | --- | --- |
| `open_chat` | `chat_id` | Активировать чат; optional `load_history` |
| `close_chat` | `chat_id` | Закрыть активный чат |
-| `send_message` | `content` или `text` | Отправить текст в активный чат |
-| `edit_message` | `message_id`, `content` | Изменить своё сообщение |
+| `send_message` | `content` или `text`; optional `mentioned_user_ids` | Отправить текст в активный чат |
+| `edit_message` | `message_id`, `content`; optional `mentioned_user_ids` | Изменить своё сообщение |
| `delete_message` | `message_id` | Soft-delete своего сообщения |
| `add_reaction` | `message_id`, `emoji` | Добавить реакцию |
| `remove_reaction` | `message_id`, `emoji` | Удалить реакцию |
@@ -87,6 +87,8 @@ ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Ес
{"action":"open_chat","chat_id":42,"load_history":false}
```
+`mentioned_user_ids` — массив максимум из 50 ID, выбранных autocomplete-ом. Consumer проверяет, что каждый пользователь имеет доступ к активному чату, и связывает ID только с реально присутствующим в тексте `@Полное имя`. События с сериализованным сообщением возвращают поле `mentions` в виде массива `{user_id, name, start, end}`. Если поле не передано при редактировании старым клиентом, существующие упоминания сохраняются и их диапазоны пересчитываются по новому тексту.
+
## События сервера
Основные публичные `type`:
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index fcfd455a..9dd72626 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -303,6 +303,13 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
box-shadow: var(--shadow-elevated);
}
+.app-menu,
+[role="menu"],
+[role="listbox"] {
+ max-width: calc(100vw - 1rem);
+ max-width: calc(100dvw - 1rem);
+}
+
.app-link-accent {
color: var(--accent-primary-strong);
}
diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx
index 0944a812..ceba6859 100644
--- a/frontend/src/app/messages/[chatId]/page.tsx
+++ b/frontend/src/app/messages/[chatId]/page.tsx
@@ -693,20 +693,27 @@ function MessageDialogPageContent() {
requestAnimationFrame(() => focusMessageInput());
};
- const handleSend = async () => {
+ const handleSend = async (mentionedUserIds: number[] = []): Promise => {
const text = messageText.trim();
let optimisticLocalId: string | null = null;
- if (!chatId || (editingMessageId && sending)) return;
- if (editingMessageId) { if (!text) return; }
- else if (!text && attachedFiles.length === 0) return;
+ if (!chatId || (editingMessageId && sending)) return false;
+ if (editingMessageId) { if (!text) return false; }
+ else if (!text && attachedFiles.length === 0) return false;
const wasNearBottom = scroll.isNearBottom();
try {
if (editingMessageId) {
setSending(true);
- const updated = await apiClient.updateMessage(editingMessageId, text);
+ const existingMentionIds = cm.messages
+ .find((message) => message.id === editingMessageId)
+ ?.mentions?.map((mention) => mention.user_id) || [];
+ const updated = await apiClient.updateMessage(
+ editingMessageId,
+ text,
+ [...new Set([...existingMentionIds, ...mentionedUserIds])],
+ );
cm.setMessages(prev => prev.map(m => m.id === editingMessageId ? { ...m, ...updated } : m));
resetComposerState();
} else {
@@ -723,21 +730,23 @@ function MessageDialogPageContent() {
resetComposerState();
const sent = files.length
- ? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId)
- : await apiClient.sendMessage(chatId, text, replyToId);
+ ? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId, mentionedUserIds)
+ : await apiClient.sendMessage(chatId, text, replyToId, mentionedUserIds);
cm.removePendingMessage(localId);
pendingRetryFilesRef.current.delete(localId);
cm.setMessages(prev => uniqueMessagesById([...prev, sent]));
if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false));
- return;
+ return true;
}
if (wasNearBottom) requestAnimationFrame(() => scroll.scrollToBottom(false));
+ return true;
} catch (e) {
console.error("Ошибка отправки сообщения:", e);
if (!editingMessageId && optimisticLocalId) cm.markPendingFailed(optimisticLocalId);
+ return false;
} finally {
if (editingMessageId) setSending(false);
}
@@ -807,6 +816,7 @@ function MessageDialogPageContent() {
const text = (message.content || "").trim();
const files = pendingRetryFilesRef.current.get(localId) || [];
const replyToId = getReplyToId(message) ?? undefined;
+ const mentionedUserIds = message.mentions?.map((mention) => mention.user_id) || [];
const wasNearBottom = scroll.isNearBottom();
setRetryingLocalIds(prev => new Set(prev).add(localId));
@@ -815,8 +825,8 @@ function MessageDialogPageContent() {
try {
const sent = files.length
- ? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId)
- : await apiClient.sendMessage(chatId, text, replyToId);
+ ? await apiClient.sendMessageWithFiles(chatId, text, files, replyToId, mentionedUserIds)
+ : await apiClient.sendMessage(chatId, text, replyToId, mentionedUserIds);
cm.removePendingMessage(localId);
pendingRetryFilesRef.current.delete(localId);
@@ -1590,6 +1600,8 @@ function MessageDialogPageContent() {
)}
+ {splitMessageMentions(message.content || "", message.mentions).map((segment, index) => (
+ segment.type === "mention" ? (
+
+ {segment.text}
+
+ ) : (
+ {segment.text}
+ )
+ ))}
+
+ );
+}
+
export default function ChatMessageItem({
message,
currentUserId,
@@ -304,9 +330,7 @@ export default function ChatMessageItem({
{message.is_deleted ? (
Сообщение удалено
) : message.content && !message.poll ? (
-
- {message.content}
-
+
) : null}
{!message.is_deleted && message.poll ? (
@@ -517,7 +541,7 @@ export default function ChatMessageItem({
{Object.keys(message.reactions_summary || {}).length > 0 ? (
-
+
{Object.entries(message.reactions_summary || {}).map(([emoji, meta]) => {
const mine = hasMyReaction(message, emoji);
const reactedUsers = reactionUsers[emoji] || [];
@@ -541,7 +565,10 @@ export default function ChatMessageItem({
{meta.count}
{showReactionPopover ? (
-
+
Отреагировали
{reactedUsers.map((person) => (
diff --git a/frontend/src/components/messages/MessageComposer.tsx b/frontend/src/components/messages/MessageComposer.tsx
index 43bb4440..231038fb 100644
--- a/frontend/src/components/messages/MessageComposer.tsx
+++ b/frontend/src/components/messages/MessageComposer.tsx
@@ -1,8 +1,19 @@
"use client";
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type React from "react";
-import { BarChart3, FileText, Paperclip, Send, Smile, X } from "lucide-react";
+import { AtSign, BarChart3, FileText, Paperclip, Send, Smile, X } from "lucide-react";
+
+import { RequestAvatar } from "@/components/requests/RequestAvatar";
+import { apiClient } from "@/lib/api";
+import {
+ activeMentionUserIds,
+ getMentionTrigger,
+ insertMention,
+ type MentionTrigger,
+ type SelectedMention,
+} from "@/lib/messages/mentions";
+import type { ChatMentionCandidate } from "@/types/api";
const MIN_COMPOSER_HEIGHT = 38;
const MAX_COMPOSER_HEIGHT = 128;
@@ -14,6 +25,7 @@ type ReplyTarget = {
};
type MessageComposerProps = {
+ chatId: number;
canSendMessages: boolean;
membershipRole?: string | null;
editingMessageId: number | null;
@@ -35,12 +47,13 @@ type MessageComposerProps = {
onInputClick: () => void;
onChangeMessage: (value: string) => void;
onTyping: () => void;
- onSend: () => void;
+ onSend: (mentionedUserIds: number[]) => Promise;
onCancelEdit: () => void;
onCancelReply: () => void;
};
export default function MessageComposer({
+ chatId,
canSendMessages,
membershipRole,
editingMessageId,
@@ -68,10 +81,89 @@ export default function MessageComposer({
}: MessageComposerProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [showAttachmentMenu, setShowAttachmentMenu] = useState(false);
+ const [mentionTrigger, setMentionTrigger] = useState(null);
+ const [mentionCandidates, setMentionCandidates] = useState([]);
+ const [selectedMentions, setSelectedMentions] = useState([]);
+ const [activeMentionIndex, setActiveMentionIndex] = useState(0);
const attachmentMenuRef = useRef(null);
const isAttachmentMenuOpen = showAttachmentMenu && !editingMessageId;
+ const isMentionMenuOpen = Boolean(mentionTrigger);
const canSend = editingMessageId ? messageText.trim().length > 0 : messageText.trim().length > 0 || attachedFiles.length > 0;
+ useEffect(() => {
+ if (!mentionTrigger) return undefined;
+ let active = true;
+ const timeoutId = window.setTimeout(() => {
+ void apiClient.searchChatMentionCandidates(chatId, mentionTrigger.query)
+ .then((response) => {
+ if (!active) return;
+ setMentionCandidates(response.results || []);
+ setActiveMentionIndex(0);
+ })
+ .catch(() => {
+ if (active) setMentionCandidates([]);
+ });
+ }, 120);
+
+ return () => {
+ active = false;
+ window.clearTimeout(timeoutId);
+ };
+ }, [chatId, mentionTrigger]);
+
+ const updateMentionTrigger = useCallback((value: string, caret: number) => {
+ let nextTrigger = getMentionTrigger(value, caret);
+ if (
+ nextTrigger
+ && selectedMentions.some((mention) => {
+ const insertedMention = `@${mention.name}`;
+ const activeText = value.slice(nextTrigger?.start ?? caret, caret);
+ return activeText.startsWith(insertedMention) && activeText.length > insertedMention.length;
+ })
+ ) {
+ nextTrigger = null;
+ }
+ setMentionTrigger(nextTrigger);
+ if (nextTrigger) {
+ setMentionCandidates([]);
+ setShowAttachmentMenu(false);
+ if (showEmojiPicker) onToggleEmojiPicker();
+ } else {
+ setMentionCandidates([]);
+ }
+ }, [onToggleEmojiPicker, selectedMentions, showEmojiPicker]);
+
+ const selectMention = useCallback((candidate: ChatMentionCandidate) => {
+ const input = messageInputRef.current;
+ if (!input || !mentionTrigger) return;
+ const result = insertMention(
+ messageText,
+ input.selectionStart ?? messageText.length,
+ mentionTrigger,
+ candidate,
+ );
+ onChangeMessage(result.value);
+ onTyping();
+ setSelectedMentions((current) => [
+ ...current.filter((mention) => mention.userId !== result.selection.userId),
+ result.selection,
+ ]);
+ setMentionTrigger(null);
+ setMentionCandidates([]);
+ requestAnimationFrame(() => {
+ input.focus();
+ input.setSelectionRange(result.caret, result.caret);
+ });
+ }, [mentionTrigger, messageInputRef, messageText, onChangeMessage, onTyping]);
+
+ const sendWithMentions = useCallback(async () => {
+ const sent = await onSend(activeMentionUserIds(messageText, selectedMentions));
+ if (!sent) return;
+ setSelectedMentions([]);
+ setMentionTrigger(null);
+ setMentionCandidates([]);
+ }, [messageText, onSend, selectedMentions]);
+
useEffect(() => {
if (!isAttachmentMenuOpen) return;
const closeMenu = (event: PointerEvent) => {
@@ -125,7 +217,12 @@ export default function MessageComposer({