Add message mentions and unread mention tracking

This commit is contained in:
lizerk 2026-08-17 16:08:14 +03:00
parent 695d7f846b
commit 53cf1918c0
34 changed files with 1408 additions and 84 deletions

View File

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

View File

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

View File

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

View File

@ -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"(?<![\w@])@{re.escape(label)}(?=$|[\s.,!?;:)\]}}])",
flags=re.IGNORECASE,
)
for match in pattern.finditer(content):
start, end = match.span()
if any(start < used_end and end > 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,
)

View File

@ -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="Упоминания",
),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: Удалить сообщение

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -693,20 +693,27 @@ function MessageDialogPageContent() {
requestAnimationFrame(() => focusMessageInput());
};
const handleSend = async () => {
const handleSend = async (mentionedUserIds: number[] = []): Promise<boolean> => {
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() {
)}
<MessageComposer
key={chatId}
chatId={chatId}
canSendMessages={canSendMessages}
membershipRole={myMembership?.role}
editingMessageId={editingMessageId}

View File

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import {
AlertCircle,
@ -35,6 +36,7 @@ import {
messageTime,
} from "@/lib/messages/messageUtils";
import type { MediaPreview } from "@/lib/media";
import { splitMessageMentions } from "@/lib/messages/mentions";
import type { Message } from "@/types/api";
function MediaTypeBadge({ type }: { type: "image" | "video" }) {
@ -131,6 +133,30 @@ function MessageDeliveryStatus({ sendState, isRead }: { sendState?: Message["sen
);
}
function MessageContent({ message, isMine }: { message: Message; isMine: boolean }) {
return (
<p className="whitespace-pre-wrap break-words text-sm leading-5 [overflow-wrap:anywhere]">
{splitMessageMentions(message.content || "", message.mentions).map((segment, index) => (
segment.type === "mention" ? (
<Link
key={`${segment.userId}-${index}`}
href={`/users/${segment.userId}`}
className={`rounded px-0.5 font-semibold underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 ${
isMine
? "bg-white/15 text-white focus-visible:ring-white/60"
: "bg-sky-500/10 text-sky-600 focus-visible:ring-sky-300"
}`}
>
{segment.text}
</Link>
) : (
<span key={`text-${index}`}>{segment.text}</span>
)
))}
</p>
);
}
export default function ChatMessageItem({
message,
currentUserId,
@ -304,9 +330,7 @@ export default function ChatMessageItem({
{message.is_deleted ? (
<p className={`italic text-sm ${isMine ? "text-sky-100" : "app-text-muted"}`}>Сообщение удалено</p>
) : message.content && !message.poll ? (
<p className="whitespace-pre-wrap break-words text-sm leading-5 [overflow-wrap:anywhere]">
{message.content}
</p>
<MessageContent message={message} isMine={isMine} />
) : null}
{!message.is_deleted && message.poll ? (
@ -517,7 +541,7 @@ export default function ChatMessageItem({
</div>
{Object.keys(message.reactions_summary || {}).length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1">
<div className={`mt-2 flex flex-wrap gap-1 ${isMine ? "justify-end" : "justify-start"}`}>
{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({
<span>{meta.count}</span>
</button>
{showReactionPopover ? (
<div className={`app-menu absolute top-full z-20 mt-1 w-56 rounded-xl p-2 ${isMine ? "right-0" : "left-0"}`}>
<div
data-reaction-users-menu="true"
className={`app-menu absolute bottom-full z-20 mb-1 w-56 rounded-xl p-2 sm:bottom-auto sm:top-full sm:mb-0 sm:mt-1 ${isMine ? "right-0" : "left-0"}`}
>
<p className="app-text-muted px-1 pb-1 text-xs font-semibold">Отреагировали</p>
<div className="max-h-56 space-y-1 overflow-y-auto">
{reactedUsers.map((person) => (

View File

@ -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<boolean>;
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<MentionTrigger | null>(null);
const [mentionCandidates, setMentionCandidates] = useState<ChatMentionCandidate[]>([]);
const [selectedMentions, setSelectedMentions] = useState<SelectedMention[]>([]);
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
const attachmentMenuRef = useRef<HTMLDivElement | null>(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({
</div>
<button
type="button"
onClick={onCancelEdit}
onClick={() => {
setSelectedMentions([]);
setMentionTrigger(null);
setMentionCandidates([]);
onCancelEdit();
}}
className="rounded-full p-0.5 text-amber-700 transition hover:bg-amber-500/10"
aria-label="Отменить редактирование"
>
@ -182,7 +279,7 @@ export default function MessageComposer({
onChange={onFilesChange}
/>
<div
className={`app-input relative min-w-0 flex-1 ${showEmojiPicker || isAttachmentMenuOpen ? "overflow-visible" : "overflow-hidden"} shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] ${
className={`app-input relative min-w-0 flex-1 ${showEmojiPicker || isAttachmentMenuOpen || isMentionMenuOpen ? "overflow-visible" : "overflow-hidden"} shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] ${
isExpanded ? "rounded-[1.5rem]" : "h-10 rounded-full"
}`}
data-composer-emoji="true"
@ -264,22 +361,92 @@ export default function MessageComposer({
</div>
) : null}
{isMentionMenuOpen ? (
<div
className="app-menu absolute bottom-full left-0 right-0 z-40 mb-2 max-h-64 overflow-y-auto rounded-xl p-1.5"
role="listbox"
aria-label="Выберите сотрудника для упоминания"
>
{mentionCandidates.length > 0 ? mentionCandidates.map((candidate, index) => (
<button
key={candidate.id}
id={`mention-candidate-${candidate.id}`}
type="button"
role="option"
aria-selected={index === activeMentionIndex}
onPointerDown={(event) => event.preventDefault()}
onClick={() => selectMention(candidate)}
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition ${
index === activeMentionIndex
? "app-selected text-[var(--foreground)]"
: "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]"
}`}
>
<RequestAvatar
alt={candidate.name}
fallback={candidate.name.split(/\s+/u).map((part) => part[0]).join("").slice(0, 2).toUpperCase() || "@"}
size="md"
src={candidate.avatar}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{candidate.name}</span>
{candidate.email ? <span className="app-text-muted block truncate text-xs">{candidate.email}</span> : null}
</span>
<AtSign size={14} className="app-text-muted shrink-0" />
</button>
)) : (
<p className="app-text-muted px-3 py-3 text-center text-xs">
Подходящих участников нет
</p>
)}
</div>
) : null}
<textarea
ref={messageInputRef}
value={messageText}
onChange={(event) => {
onChangeMessage(event.target.value);
onTyping();
updateMentionTrigger(
event.target.value,
event.target.selectionStart ?? event.target.value.length,
);
}}
onClick={() => {
setShowAttachmentMenu(false);
onInputClick();
const input = messageInputRef.current;
if (input) updateMentionTrigger(input.value, input.selectionStart ?? input.value.length);
}}
onPaste={handlePaste}
onKeyDown={(event) => {
if (isMentionMenuOpen && mentionCandidates.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveMentionIndex((index) => (index + 1) % mentionCandidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveMentionIndex((index) => (index - 1 + mentionCandidates.length) % mentionCandidates.length);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
selectMention(mentionCandidates[activeMentionIndex]);
return;
}
}
if (event.key === "Escape" && isMentionMenuOpen) {
event.preventDefault();
setMentionTrigger(null);
setMentionCandidates([]);
return;
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
void sendWithMentions();
}
}}
rows={1}
@ -293,7 +460,7 @@ export default function MessageComposer({
<button
type="button"
onClick={onSend}
onClick={() => void sendWithMentions()}
disabled={sending || !canSend}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm shadow-sky-200/70 transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:border disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-400 disabled:shadow-none disabled:opacity-100"
title={editingMessageId ? "Сохранить" : "Отправить"}

View File

@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import {
AtSign,
Bell,
BellOff,
Building2,
@ -86,6 +87,7 @@ export default function MessagesChatListItem({
const title = getChatTitle(chat, currentUserId, identity);
const avatar = getChatAvatar(chat, currentUserId);
const unread = chat.unread_count ?? 0;
const hasUnreadMention = Boolean(chat.has_unread_mention);
const [menuOpen, setMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<"pin" | "notifications" | null>(null);
@ -169,6 +171,16 @@ export default function MessagesChatListItem({
{unread > 99 ? "99+" : unread}
</span>
) : null}
{hasUnreadMention ? (
<span
data-testid="messages-chat-mention-badge"
className="absolute -bottom-0.5 -right-0.5 z-20 flex h-4 w-4 items-center justify-center rounded-full border-2 border-[var(--surface-primary)] bg-sky-500 text-white shadow-sm"
title="Вас упомянули в этом чате"
aria-label="Вас упомянули в этом чате"
>
<AtSign size={9} strokeWidth={3} />
</span>
) : null}
</span>
</Link>
);
@ -209,6 +221,16 @@ export default function MessagesChatListItem({
{unread > 99 ? "99+" : unread}
</span>
) : null}
{hasUnreadMention ? (
<span
data-testid="messages-chat-mention-badge"
className="absolute -bottom-0.5 -right-0.5 z-20 flex h-4 w-4 items-center justify-center rounded-full border-2 border-[var(--surface-primary)] bg-sky-500 text-white shadow-sm"
title="Вас упомянули в этом чате"
aria-label="Вас упомянули в этом чате"
>
<AtSign size={9} strokeWidth={3} />
</span>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">

View File

@ -5,6 +5,8 @@ import type { ReactNode, RefObject } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { clampDropdownToViewport } from "@/lib/dropdown-position";
type DropdownPosition = {
left: number;
maxHeight: number;
@ -16,18 +18,25 @@ function getDropdownPosition(anchor: HTMLElement): DropdownPosition {
const rect = anchor.getBoundingClientRect();
const gap = 4;
const viewportPadding = 8;
const viewportWidth = window.innerWidth;
const preferredHeight = 224;
const belowSpace = window.innerHeight - rect.bottom - viewportPadding;
const aboveSpace = rect.top - viewportPadding;
const openUp = belowSpace < 180 && aboveSpace > belowSpace;
const availableHeight = Math.max(120, openUp ? aboveSpace : belowSpace);
const maxHeight = Math.min(preferredHeight, availableHeight);
const { left, width } = clampDropdownToViewport({
anchorLeft: rect.left,
anchorWidth: rect.width,
viewportWidth,
viewportPadding,
});
return {
left: rect.left,
left,
maxHeight,
top: openUp ? rect.top - maxHeight - gap : rect.bottom + gap,
width: rect.width,
width,
};
}

View File

@ -210,7 +210,7 @@ function BoardRow({
<ChevronRight size={14} className={`transition-transform ${menuOpen ? "rotate-90" : ""}`} />
</button>
{menuOpen ? (
<div className="app-surface-elevated absolute right-2 top-10 z-30 min-w-52 overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<div className="app-surface-elevated absolute right-2 top-10 z-30 min-w-52 max-w-[calc(100dvw-1rem)] overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<button
type="button"
onClick={onEdit}
@ -646,7 +646,7 @@ export default function TaskBoardNavigator({
<ChevronRight size={12} className={`transition-transform ${groupMenuId === group.id ? "rotate-90" : ""}`} />
</button>
{groupMenuId === group.id ? (
<div className="app-surface-elevated absolute left-0 top-8 z-30 min-w-44 overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<div className="app-surface-elevated absolute left-0 top-8 z-30 min-w-44 max-w-[calc(100dvw-1rem)] overflow-hidden rounded-lg border border-[var(--border-subtle)] py-1 shadow-xl">
<button
type="button"
onClick={() => {

View File

@ -117,13 +117,18 @@ export function markChatReadLocally(chatId: number, lastReadMessageId?: number)
const confirmedId = typeof lastReadMessageId === "number"
? Math.max(chat.last_read_message_id ?? 0, lastReadMessageId)
: chat.last_read_message_id;
if ((chat.unread_count ?? 0) === 0 && confirmedId === chat.last_read_message_id) {
if (
(chat.unread_count ?? 0) === 0
&& !chat.has_unread_mention
&& confirmedId === chat.last_read_message_id
) {
return chat;
}
changed = true;
return {
...chat,
unread_count: 0,
has_unread_mention: false,
last_read_message_id: confirmedId,
};
});

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
ChatMessageSearchResponse,
ChatMentionCandidate,
CreatePollPayload,
CreatePollResponse,
MessagePoll,
@ -9,11 +10,12 @@ import type {
import type { RequestFn } from './utils';
export function createMessagesApi(request: RequestFn) {
async function uploadMessage(chatId: number | string, text: string, files: File[], replyTo?: number) {
async function uploadMessage(chatId: number | string, text: string, files: File[], replyTo?: number, mentionedUserIds: number[] = []) {
const fd = new FormData();
fd.append('content', text);
fd.append('chat_id', chatId.toString());
if (replyTo) fd.append('reply_to', replyTo.toString());
fd.append('mentioned_user_ids', JSON.stringify([...new Set(mentionedUserIds)]));
files.forEach((file, i) => fd.append(`file_${i}`, file));
const result = await request('/api/v1/communications/messages/upload/', {
method: 'POST',
@ -97,9 +99,14 @@ export function createMessagesApi(request: RequestFn) {
if (typeof params.offset === 'number') qp.append('offset', params.offset.toString());
return request(`/api/v1/communications/chats/${chatId}/search-messages/?${qp.toString()}`) as Promise<ChatMessageSearchResponse>;
},
sendMessage: (chatId: number | string, text: string, replyTo?: number) => uploadMessage(chatId, text, [], replyTo),
sendMessageWithFiles: (chatId: number | string, text: string, files: File[], replyTo?: number) => uploadMessage(chatId, text, files, replyTo),
updateMessage: (messageId: number, text: string) => request(`/api/v1/communications/messages/${messageId}/`, { method: 'PATCH', body: JSON.stringify({ content: text }) }),
searchChatMentionCandidates: (chatId: number | string, query = '') => {
const qp = new URLSearchParams();
if (query) qp.set('q', query);
return request(`/api/v1/communications/chats/${chatId}/mention-candidates/?${qp.toString()}`) as Promise<{ results: ChatMentionCandidate[] }>;
},
sendMessage: (chatId: number | string, text: string, replyTo?: number, mentionedUserIds: number[] = []) => uploadMessage(chatId, text, [], replyTo, mentionedUserIds),
sendMessageWithFiles: (chatId: number | string, text: string, files: File[], replyTo?: number, mentionedUserIds: number[] = []) => uploadMessage(chatId, text, files, replyTo, mentionedUserIds),
updateMessage: (messageId: number, text: string, mentionedUserIds: number[] = []) => request(`/api/v1/communications/messages/${messageId}/`, { method: 'PATCH', body: JSON.stringify({ content: text, mentioned_user_ids: mentionedUserIds }) }),
deleteMessage: (messageId: number): Promise<void> => request(`/api/v1/communications/messages/${messageId}/`, { method: 'DELETE' }),
markChatAsRead: (chatId: number, lastReadMessageId?: number) => request(`/api/v1/communications/chats/${chatId}/mark-read/`, { method: 'POST', body: JSON.stringify({ message_id: lastReadMessageId }) }),
togglePinChat: (chatId: number) => request(`/api/v1/communications/chats/${chatId}/pin/`, { method: 'POST' }),

View File

@ -10,6 +10,7 @@ test("confirmed chat reads clear a stale unread counter", () => {
const chat = {
id: 7,
unread_count: 3,
has_unread_mention: true,
last_read_message_id: 40,
last_message: { id: 43 },
};
@ -17,6 +18,7 @@ test("confirmed chat reads clear a stale unread counter", () => {
assert.deepEqual(applyChatReadWatermark(chat, 43), {
...chat,
unread_count: 0,
has_unread_mention: false,
last_read_message_id: 43,
});
});

View File

@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import { clampDropdownToViewport } from "./dropdown-position.ts";
test("dropdown keeps the anchor width when it fits inside the viewport", () => {
assert.deepEqual(clampDropdownToViewport({
anchorLeft: 24,
anchorWidth: 240,
viewportWidth: 390,
}), { left: 24, width: 240 });
});
test("dropdown moves inside the right viewport edge", () => {
assert.deepEqual(clampDropdownToViewport({
anchorLeft: 220,
anchorWidth: 240,
viewportWidth: 390,
}), { left: 142, width: 240 });
});
test("dropdown shrinks to the safe mobile viewport width", () => {
assert.deepEqual(clampDropdownToViewport({
anchorLeft: -30,
anchorWidth: 420,
viewportWidth: 320,
}), { left: 8, width: 304 });
});

View File

@ -0,0 +1,27 @@
type ClampDropdownToViewportOptions = {
anchorLeft: number;
anchorWidth: number;
viewportWidth: number;
viewportPadding?: number;
};
export type ClampedDropdownHorizontalPosition = {
left: number;
width: number;
};
export function clampDropdownToViewport({
anchorLeft,
anchorWidth,
viewportWidth,
viewportPadding = 8,
}: ClampDropdownToViewportOptions): ClampedDropdownHorizontalPosition {
const availableWidth = Math.max(0, viewportWidth - viewportPadding * 2);
const width = Math.min(Math.max(0, anchorWidth), availableWidth);
const left = Math.min(
Math.max(viewportPadding, anchorLeft),
Math.max(viewportPadding, viewportWidth - width - viewportPadding),
);
return { left, width };
}

View File

@ -5,6 +5,7 @@ export function applyChatReadWatermark(chat, watermark) {
return {
...chat,
unread_count: 0,
has_unread_mention: false,
last_read_message_id: Math.max(chat.last_read_message_id ?? 0, watermark),
};
}

View File

@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
activeMentionUserIds,
getMentionTrigger,
insertMention,
splitMessageMentions,
} from "./mentions.ts";
test("mention autocomplete starts only at a word boundary", () => {
assert.deepEqual(getMentionTrigger("Привет @анд", 11), { query: "анд", start: 7 });
assert.equal(getMentionTrigger("mail@test", 9), null);
});
test("mention autocomplete supports a three-part full name", () => {
const value = "Привет @иванов петр сергеевич";
assert.deepEqual(getMentionTrigger(value, value.length), {
query: "иванов петр сергеевич",
start: 7,
});
assert.equal(
getMentionTrigger("@иванов петр сергеевич продолжение", 36),
null,
);
});
test("mention selection replaces the active query and keeps surrounding text", () => {
assert.deepEqual(
insertMention("Привет @ан завтра", 10, { query: "ан", start: 7 }, { id: 42, name: "Анна Иванова" }),
{
value: "Привет @Анна Иванова завтра",
caret: 20,
selection: { userId: 42, name: "Анна Иванова" },
},
);
});
test("deleted mentions are not sent to the API", () => {
const selections = [{ userId: 42, name: "Анна Иванова" }];
assert.deepEqual(activeMentionUserIds("Привет @Анна Иванова", selections), [42]);
assert.deepEqual(activeMentionUserIds("Привет всем", selections), []);
});
test("stored mention ranges produce safe message segments", () => {
assert.deepEqual(
splitMessageMentions("Привет @Анна Иванова!", [
{ user_id: 42, name: "Анна Иванова", start: 7, end: 20 },
]),
[
{ type: "text", text: "Привет " },
{ type: "mention", text: "@Анна Иванова", userId: 42 },
{ type: "text", text: "!" },
],
);
});

View File

@ -0,0 +1,79 @@
import type { ChatMentionCandidate, MessageMention } from "@/types/api";
export type MentionTrigger = {
query: string;
start: number;
};
export type SelectedMention = {
userId: number;
name: string;
};
export type MessageTextSegment =
| { type: "text"; text: string }
| { type: "mention"; text: string; userId: number };
export function getMentionTrigger(text: string, caret: number): MentionTrigger | null {
const beforeCaret = text.slice(0, Math.max(0, caret));
const match = beforeCaret.match(/(?:^|\s)@([^@\n,;:!?]{0,100})$/u);
if (!match) return null;
const query = match[1].trim();
if (query.split(/\s+/u).filter(Boolean).length > 3) return null;
const atOffset = match[0].lastIndexOf("@");
return {
query,
start: beforeCaret.length - match[0].length + atOffset,
};
}
export function insertMention(
text: string,
caret: number,
trigger: MentionTrigger,
candidate: ChatMentionCandidate,
) {
const trailingSpace = /\s/u.test(text.charAt(caret)) ? "" : " ";
const insertion = `@${candidate.name}${trailingSpace}`;
const value = `${text.slice(0, trigger.start)}${insertion}${text.slice(caret)}`;
return {
value,
caret: trigger.start + insertion.length,
selection: { userId: candidate.id, name: candidate.name } satisfies SelectedMention,
};
}
export function activeMentionUserIds(
text: string,
selections: SelectedMention[],
): number[] {
const ids = new Set<number>();
for (const selection of selections) {
if (text.includes(`@${selection.name}`)) ids.add(selection.userId);
}
return [...ids];
}
export function splitMessageMentions(
content: string,
mentions: MessageMention[] | undefined,
): MessageTextSegment[] {
const sorted = [...(mentions || [])].sort((left, right) => left.start - right.start);
const segments: MessageTextSegment[] = [];
let cursor = 0;
for (const mention of sorted) {
if (!Number.isInteger(mention.start) || !Number.isInteger(mention.end)) continue;
if (mention.start < cursor || mention.end <= mention.start || mention.end > content.length) continue;
const text = content.slice(mention.start, mention.end);
if (!text.startsWith("@")) continue;
if (mention.start > cursor) {
segments.push({ type: "text", text: content.slice(cursor, mention.start) });
}
segments.push({ type: "mention", text, userId: mention.user_id });
cursor = mention.end;
}
if (cursor < content.length) segments.push({ type: "text", text: content.slice(cursor) });
return segments.length > 0 ? segments : [{ type: "text", text: content }];
}

View File

@ -7,6 +7,10 @@ import { getVerbCategory } from "./verbTranslations.ts";
const notificationSamples = [
["feed", "feed_new_post"],
["messages", "chat_new_message"],
["messages", "chat_mention"],
["messages", "chat_reply"],
["messages", "chat_added_to_chat"],
["messages", "announcement_new_message"],
["requests", "request_new"],
["guests", "guest_visit_submitted"],
["procurement", "procurement_new_request"],

View File

@ -30,10 +30,10 @@ export const VERB_CATEGORIES: Record<string, string> = {
// Сообщения и чат
chat_new_message: 'Сообщения',
chat_mention: 'Упоминания',
chat_mention: 'Сообщения',
chat_reply: 'Сообщения',
chat_added_to_chat: 'Чат',
announcement_new_message: 'Объявления',
chat_added_to_chat: 'Сообщения',
announcement_new_message: 'Сообщения',
message_received: 'Сообщения',
commented: 'Комментарии',
liked: 'Реакции',

View File

@ -1493,6 +1493,7 @@ export interface Chat {
participant_details?: Array<{ id: number; name?: string; avatar?: string | null }>;
last_message?: ChatLastMessage | null;
unread_count?: number;
has_unread_mention?: boolean;
created_at: string;
created_by?: number;
is_pinned?: boolean;
@ -1530,6 +1531,13 @@ export interface ChatMembership {
can_manage_members?: boolean;
}
export interface ChatMentionCandidate {
id: number;
name: string;
email?: string;
avatar?: string | null;
}
export interface ChatUserSettings {
id?: number;
is_pinned: boolean;
@ -1550,6 +1558,7 @@ export interface Message {
author_name?: string;
avatar?: string;
content: string;
mentions?: MessageMention[];
is_read?: boolean;
read_count?: number;
read_by?: MessageReader[];
@ -1579,6 +1588,13 @@ export interface Message {
poll?: MessagePoll;
}
export interface MessageMention {
user_id: number;
name: string;
start: number;
end: number;
}
export interface MessageReplyPreview {
id: number;
content: string;