Add message mentions and unread mention tracking
This commit is contained in:
parent
695d7f846b
commit
53cf1918c0
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
194
backend/communications/mentions.py
Normal file
194
backend/communications/mentions.py
Normal 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,
|
||||
)
|
||||
20
backend/communications/migrations/0040_message_mentions.py
Normal file
20
backend/communications/migrations/0040_message_mentions.py
Normal 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="Упоминания",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -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: Удалить сообщение
|
||||
|
||||
295
backend/tests/api/v1/communications/test_mentions.py
Normal file
295
backend/tests/api/v1/communications/test_mentions.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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`:
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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 ? "Сохранить" : "Отправить"}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
28
frontend/src/lib/dropdown-position.test.js
Normal file
28
frontend/src/lib/dropdown-position.test.js
Normal 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 });
|
||||
});
|
||||
27
frontend/src/lib/dropdown-position.ts
Normal file
27
frontend/src/lib/dropdown-position.ts
Normal 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 };
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
56
frontend/src/lib/messages/mentions.test.js
Normal file
56
frontend/src/lib/messages/mentions.test.js
Normal 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: "!" },
|
||||
],
|
||||
);
|
||||
});
|
||||
79
frontend/src/lib/messages/mentions.ts
Normal file
79
frontend/src/lib/messages/mentions.ts
Normal 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 }];
|
||||
}
|
||||
@ -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"],
|
||||
|
||||
@ -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: 'Реакции',
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user