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({
+ )) : ( +

+ Подходящих участников нет +

+ )} +
+ ) : null} +