feat(messages): add polls and realtime read state

This commit is contained in:
lizerk 2026-08-06 14:37:18 +03:00
parent 5214d6fa45
commit 96d4a188b7
22 changed files with 1614 additions and 220 deletions

View File

@ -2,11 +2,17 @@
Serializers for Communications API
"""
from datetime import timedelta
import rules
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.utils import timezone
from rest_framework import serializers
from ..models import (
Chat,
Message,
@ -17,7 +23,8 @@ from ..models import (
ChatMembership,
ChatUserSettings,
)
from ..serialization import serialize_message
from ..serialization import serialize_message, serialize_poll
from ..utils import user_can_access_chat
User = get_user_model()
@ -461,15 +468,22 @@ class PollSerializer(serializers.ModelSerializer):
options_data = serializers.ListField(
child=serializers.CharField(max_length=200),
write_only=True,
required=False,
required=True,
min_length=2,
max_length=10,
help_text="Список вариантов ответов (строки)",
)
chat_id = serializers.IntegerField(write_only=True, required=True)
reply_to_id = serializers.IntegerField(write_only=True, required=False)
correct_option_index = serializers.IntegerField(
write_only=True, required=False, allow_null=True
)
closes_in_minutes = serializers.IntegerField(
write_only=True, required=False, allow_null=True
write_only=True,
required=False,
allow_null=True,
min_value=1,
max_value=525_600,
)
class Meta:
@ -490,6 +504,7 @@ class PollSerializer(serializers.ModelSerializer):
# write_only поля
"options_data",
"chat_id",
"reply_to_id",
"correct_option_index",
"closes_in_minutes",
]
@ -506,92 +521,134 @@ class PollSerializer(serializers.ModelSerializer):
return [v.option_id for v in votes]
return []
def to_representation(self, instance):
request = self.context.get("request")
user = request.user if request and request.user.is_authenticated else None
return serialize_poll(instance, user=user)
def validate_question(self, value):
question = value.strip()
if not question:
raise serializers.ValidationError("Введите вопрос")
return question
def validate(self, attrs):
request = self.context.get("request")
if not request or not request.user.is_authenticated:
raise serializers.ValidationError("Требуется авторизация")
try:
chat = Chat.objects.get(pk=attrs["chat_id"])
except Chat.DoesNotExist:
raise serializers.ValidationError(
{"chat_id": "Чат не найден"}
) from None
if not user_can_access_chat(chat, request.user):
raise serializers.ValidationError(
{"chat_id": "Нет доступа к этому чату"}
)
if not rules.test_rule(
"communications.send_message",
request.user,
chat,
):
raise serializers.ValidationError(
{"chat_id": "Нет права отправлять сообщения в этот чат"}
)
normalized_options = [option.strip() for option in attrs["options_data"]]
if any(not option for option in normalized_options):
raise serializers.ValidationError(
{"options_data": "Варианты не могут быть пустыми"}
)
if len({option.casefold() for option in normalized_options}) != len(
normalized_options
):
raise serializers.ValidationError(
{"options_data": "Варианты не должны повторяться"}
)
attrs["options_data"] = normalized_options
correct_option_index = attrs.get("correct_option_index")
if attrs.get("is_quiz"):
if attrs.get("is_multiple_choice"):
raise serializers.ValidationError(
{"is_multiple_choice": "Викторина допускает только один ответ"}
)
if correct_option_index is None or not (
0 <= correct_option_index < len(normalized_options)
):
raise serializers.ValidationError(
{"correct_option_index": "Выберите правильный вариант"}
)
elif correct_option_index is not None:
raise serializers.ValidationError(
{"correct_option_index": "Правильный ответ доступен только для викторины"}
)
if attrs.get("allows_custom_answers"):
raise serializers.ValidationError(
{"allows_custom_answers": "Свои варианты ответа пока не поддерживаются"}
)
self._reply_to = None
reply_to_id = attrs.get("reply_to_id")
if reply_to_id is not None:
try:
self._reply_to = chat.messages.get(
pk=reply_to_id,
is_deleted=False,
)
except Message.DoesNotExist:
raise serializers.ValidationError(
{"reply_to_id": "Исходное сообщение не найдено"}
) from None
self._poll_chat = chat
return attrs
def create(self, validated_data):
"""Создание голосования с опциями"""
from communications.models import Chat, Message
from datetime import timedelta
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
logger.info(f"[PollSerializer.create] validated_data: {validated_data}")
# Извлекаем данные для создания
options_data = validated_data.pop("options_data", [])
chat_id = validated_data.pop("chat_id")
options_data = validated_data.pop("options_data")
validated_data.pop("chat_id")
validated_data.pop("reply_to_id", None)
correct_option_index = validated_data.pop("correct_option_index", None)
closes_in_minutes = validated_data.pop("closes_in_minutes", None)
logger.info(
f"[PollSerializer.create] options_data: {options_data}, "
f"chat_id: {chat_id}"
)
# Получаем автора (может быть передан через perform_create или взять из
# запроса)
author = (
validated_data.pop("author", None)
or self.context.get("request").user
)
# Создаем сообщение в чате
try:
chat = Chat.objects.get(id=chat_id)
except Chat.DoesNotExist:
logger.error(f"[PollSerializer.create] Chat {chat_id} not found")
raise serializers.ValidationError({"chat_id": "Chat not found"})
message = Message.objects.create(
chat=chat,
author=author,
content=f"📊 {validated_data['question']}",
is_system=False,
)
# Устанавливаем closes_at если указано
if closes_in_minutes and closes_in_minutes > 0:
if closes_in_minutes:
validated_data["closes_at"] = timezone.now() + timedelta(
minutes=closes_in_minutes
)
# Создаем голосование
poll = Poll.objects.create(
message=message, author=author, **validated_data
)
# Создаем опции
for index, option_text in enumerate(options_data):
is_correct = (
correct_option_index is not None
and index == correct_option_index
with transaction.atomic():
message = Message.objects.create(
chat=self._poll_chat,
author=author,
content=validated_data["question"],
reply_to=self._reply_to,
is_system=False,
)
poll.options.create(
text=option_text, position=index, is_correct=is_correct
poll = Poll.objects.create(
message=message,
author=author,
**validated_data,
)
PollOption.objects.bulk_create(
[
PollOption(
poll=poll,
text=option_text,
position=index,
is_correct=(index == correct_option_index),
)
for index, option_text in enumerate(options_data)
]
)
# Отправляем WebSocket уведомление о новом сообщении
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from communications.serialization import serialize_message
# Перезагружаем сообщение с poll и options для полной сериализации
message.refresh_from_db()
message_data = serialize_message(message)
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
f"chat_{chat.id}",
{
"type": "chat_message",
"chat_id": chat.id,
"payload": message_data,
},
)
logger.info(
"[PollSerializer.create] WebSocket notification sent "
f"for message {message.id}"
)
return poll

View File

@ -51,10 +51,14 @@ from ..models import (
MessageAttachment,
MessageReaction,
Poll,
PollVote,
)
from ..polls import PollActionError, cast_poll_vote
from ..utils import _coerce_ts, user_can_access_chat
from communications.serialization import serialize_message
from communications.serialization import (
serialize_message,
serialize_message_reader,
serialize_poll,
)
logger = logging.getLogger(__name__)
User = get_user_model()
@ -612,7 +616,7 @@ class ChatViewSet(viewsets.ModelViewSet):
user.id}, chat={
chat.id}, msg={
last_message.id}")
self._send_marked_read_event(user.id, chat.id, last_message.id)
self._send_marked_read_event(user, chat.id, last_message.id)
return
# Защита от откатов: только если НОВЕЕ
@ -636,19 +640,21 @@ class ChatViewSet(viewsets.ModelViewSet):
user.id}, chat={
chat.id}, msg={
last_message.id}")
self._send_marked_read_event(user.id, chat.id, last_message.id)
self._send_marked_read_event(user, chat.id, last_message.id)
def _send_marked_read_event(self, user_id, chat_id, message_id):
def _send_marked_read_event(self, user, chat_id, message_id):
"""WebSocket событие для синхронизации и read receipts."""
channel_layer = get_channel_layer()
reader = serialize_message_reader(user)
event_payload = {
'chat_id': chat_id,
'last_read_message_id': message_id,
'reader_user_id': user_id,
'reader_user_id': user.id,
'reader': reader,
}
async_to_sync(channel_layer.group_send)(
f'user_{user_id}',
f'user_{user.id}',
{
'type': 'chat_marked_read_sync',
**event_payload,
@ -1724,80 +1730,97 @@ class PollViewSet(viewsets.ModelViewSet):
- results: GET /api/v1/communications/polls/{id}/results/ - результаты
"""
queryset = Poll.objects.all()
queryset = Poll.objects.select_related(
'message',
'message__chat',
'author',
).prefetch_related('options')
serializer_class = PollSerializer
permission_classes = [IsAuthenticated]
http_method_names = ['get', 'post', 'head', 'options']
def perform_create(self, serializer):
"""Автоматически устанавливаем автора голосования"""
serializer.save(author=self.request.user)
def get_queryset(self):
queryset = super().get_queryset()
user = self.request.user
if user.is_superuser:
return queryset
@action(detail=True, methods=['post'])
def vote(self, request, pk=None):
"""Проголосовать"""
poll = self.get_object()
if poll.is_closed:
return Response(
{'error': 'Poll is closed'},
status=status.HTTP_400_BAD_REQUEST
accessible_filter = (
Q(message__chat__type='global')
| Q(message__chat__participants=user)
| Q(
message__chat__memberships__user=user,
message__chat__memberships__is_active=True,
)
| Q(message__chat__include_all_users=True)
)
option_ids = request.data.get('option_ids', [])
if not option_ids:
return Response(
{'error': 'No options selected'},
status=status.HTTP_400_BAD_REQUEST
)
context_chat_ids = [
chat.id
for chat in Chat.objects.filter(
context_object_id__isnull=False,
).select_related('context_content_type')
if user_can_access_chat(chat, user)
]
if context_chat_ids:
accessible_filter |= Q(message__chat_id__in=context_chat_ids)
# Проверка что опции существуют и принадлежат этому poll
try:
options = poll.options.filter(pk__in=option_ids)
if options.count() != len(option_ids):
return Response(
{'error': 'Invalid option IDs'},
status=status.HTTP_400_BAD_REQUEST
)
except (ValueError, TypeError):
return Response(
{'error': 'Invalid option IDs format'},
status=status.HTTP_400_BAD_REQUEST
)
return queryset.filter(accessible_filter).distinct()
# Проверка множественного выбора
if not poll.is_multiple_choice and len(option_ids) > 1:
return Response(
{'error': 'Only one option allowed'},
status=status.HTTP_400_BAD_REQUEST
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
poll = serializer.save(author=request.user)
with transaction.atomic():
# Удаляем ВСЕ старые голоса пользователя (и для single и для multiple
# choice)
PollVote.objects.filter(
poll=poll,
voter=request.user
).delete()
message = Message.objects.select_related(
'author',
'reply_to',
'reply_to__author',
'poll',
'chat',
).prefetch_related(
'attachments',
'reactions',
'reactions__user',
'poll__options',
Prefetch(
'chat__read_states',
queryset=ChatReadState.objects.select_related('user'),
),
).get(pk=poll.message_id)
# Добавляем новые голоса
for option in options:
PollVote.objects.create(
poll=poll,
option=option,
voter=request.user
)
websocket_payload = serialize_message(message)
response_message = serialize_message(
message,
user=request.user,
include_linked_tasks=True,
)
async_to_sync(get_channel_layer().group_send)(
f'chat_{message.chat_id}',
{
'type': 'chat.message',
'chat_id': message.chat_id,
'payload': websocket_payload,
},
)
# Пересчитываем голоса
for option in poll.options.all():
option.vote_count = option.votes.count()
option.save()
return Response(
{
'poll': serialize_poll(poll, user=request.user),
'message': response_message,
},
status=status.HTTP_201_CREATED,
)
poll.total_voters = PollVote.objects.filter(
poll=poll).values('voter').distinct().count()
poll.save()
# WebSocket уведомление
def _broadcast_update(
self,
poll,
*,
voter_user_id=None,
selected_option_ids=None,
):
channel_layer = get_channel_layer()
results = serialize_poll(poll)
async_to_sync(channel_layer.group_send)(
f'chat_{poll.message.chat_id}',
{
@ -1806,35 +1829,75 @@ class PollViewSet(viewsets.ModelViewSet):
'payload': {
'poll_id': poll.id,
'message_id': poll.message_id,
'total_voters': poll.total_voters
}
}
'results': results,
},
},
)
# Возвращаем полные результаты
results_serializer = PollSerializer(poll, context={'request': request})
return Response({'ok': True, 'results': results_serializer.data})
if voter_user_id is not None:
async_to_sync(channel_layer.group_send)(
f'user_{voter_user_id}',
{
'type': 'poll.update.sync',
'chat_id': poll.message.chat_id,
'payload': {
'poll_id': poll.id,
'message_id': poll.message_id,
'results': results,
'voter_user_id': voter_user_id,
'selected_option_ids': selected_option_ids or [],
},
},
)
@action(detail=True, methods=['post'])
def vote(self, request, pk=None):
"""Проголосовать"""
poll = self.get_object()
option_ids = request.data.get('option_ids', [])
try:
poll, selected_option_ids = cast_poll_vote(
poll,
request.user,
option_ids,
)
except PollActionError as exc:
return Response(
{'error': exc.message},
status=exc.status_code,
)
self._broadcast_update(
poll,
voter_user_id=request.user.id,
selected_option_ids=selected_option_ids,
)
return Response(
{
'ok': True,
'results': serialize_poll(poll, user=request.user),
}
)
@action(detail=True, methods=['post'])
def close(self, request, pk=None):
"""Закрыть голосование"""
poll = self.get_object()
# Проверка прав (только автор сообщения)
if poll.message.author != request.user:
if poll.author != request.user and not request.user.is_superuser:
return Response(
{'error': 'Only poll author can close it'},
{'error': 'Завершить голосование может только автор'},
status=status.HTTP_403_FORBIDDEN
)
# Используем метод модели для установки is_closed и closed_at
poll.close()
self._broadcast_update(poll)
return Response(PollSerializer(poll, context={'request': request}).data)
return Response(serialize_poll(poll, user=request.user))
@action(detail=True, methods=['get', 'post'])
def results(self, request, pk=None):
"""Получить результаты голосования"""
poll = self.get_object()
serializer = PollSerializer(poll, context={'request': request})
return Response(serializer.data)
return Response(serialize_poll(poll, user=request.user))

View File

@ -16,8 +16,16 @@ from channels.db import database_sync_to_async
from django.db.models import Prefetch, Q
from django.utils import timezone
from .models import Chat, ChatMembership, ChatReadState, Message, MessageReaction
from .serialization import serialize_message
from .models import (
Chat,
ChatMembership,
ChatReadState,
Message,
MessageReaction,
Poll,
)
from .polls import PollActionError, cast_poll_vote
from .serialization import serialize_message, serialize_poll
from .utils import user_can_access_chat
logger = logging.getLogger(__name__)
@ -297,6 +305,11 @@ class ChatConsumerMixin:
"poll_id": payload.get("poll_id"),
"message_id": payload.get("message_id"),
"results": payload.get("results"),
"voter_user_id": payload.get("voter_user_id"),
"selected_option_ids": payload.get(
"selected_option_ids",
[],
),
}
)
@ -304,6 +317,7 @@ class ChatConsumerMixin:
chat_id = event.get("chat_id")
last_read_message_id = event.get("last_read_message_id")
reader_user_id = event.get("reader_user_id")
reader = event.get("reader")
if chat_id != self.active_chat_id:
return
@ -323,6 +337,7 @@ class ChatConsumerMixin:
"chat_id": chat_id,
"last_read_message_id": last_read_message_id,
"reader_user_id": reader_user_id,
"reader": reader,
}
)
@ -565,8 +580,60 @@ class ChatConsumerMixin:
async def _handle_vote_poll(self, content):
"""Голосование в опросе"""
# TODO: Реализовать логику голосования
pass
poll_id = content.get("poll_id")
option_ids = content.get("option_ids")
if not self.active_chat_id or not poll_id or not isinstance(option_ids, list):
await self.send_json(
{
"type": "error",
"code": "poll_vote_failed",
"error": "Некорректные данные голосования",
}
)
return
result = await self._cast_poll_vote(
poll_id,
option_ids,
self.active_chat_id,
self.user,
)
if not result["ok"]:
await self.send_json(
{
"type": "error",
"code": "poll_vote_failed",
"error": result["error"],
}
)
return
await self.channel_layer.group_send(
f"chat_{self.active_chat_id}",
{
"type": "chat_poll_update",
"chat_id": self.active_chat_id,
"payload": {
"poll_id": result["poll_id"],
"message_id": result["message_id"],
"results": result["results"],
},
},
)
await self.channel_layer.group_send(
f"user_{self.user.id}",
{
"type": "poll_update_sync",
"chat_id": self.active_chat_id,
"payload": {
"poll_id": result["poll_id"],
"message_id": result["message_id"],
"results": result["results"],
"voter_user_id": self.user.id,
"selected_option_ids": result["selected_option_ids"],
},
},
)
# ==================== Вспомогательные методы ====================
@ -695,6 +762,33 @@ class ChatConsumerMixin:
).delete()
return deleted_count > 0
@database_sync_to_async
def _cast_poll_vote(self, poll_id, option_ids, chat_id, user):
try:
poll = Poll.objects.select_related(
"message",
"message__chat",
).get(pk=poll_id, message__chat_id=chat_id)
except Poll.DoesNotExist:
return {"ok": False, "error": "Голосование не найдено"}
try:
poll, selected_option_ids = cast_poll_vote(
poll,
user,
option_ids,
)
except PollActionError as exc:
return {"ok": False, "error": exc.message}
return {
"ok": True,
"poll_id": poll.id,
"message_id": poll.message_id,
"selected_option_ids": selected_option_ids,
"results": serialize_poll(poll),
}
@database_sync_to_async
def _mark_read(self, chat, user):
"""

View File

@ -0,0 +1,100 @@
from __future__ import annotations
from dataclasses import dataclass
from django.db import transaction
from django.db.models import Count
from django.utils import timezone
from .models import Poll, PollOption, PollVote
from .utils import user_can_access_chat
@dataclass
class PollActionError(Exception):
message: str
status_code: int = 400
def __str__(self):
return self.message
def poll_is_effectively_closed(poll: Poll) -> bool:
return bool(
poll.is_closed
or (poll.closes_at and poll.closes_at <= timezone.now())
)
def cast_poll_vote(poll: Poll, user, option_ids) -> tuple[Poll, list[int]]:
"""Replace a user's poll selection and refresh denormalized counters."""
try:
normalized_ids = list(dict.fromkeys(int(option_id) for option_id in option_ids))
except (TypeError, ValueError):
raise PollActionError("Некорректные варианты ответа") from None
if not normalized_ids:
raise PollActionError("Выберите хотя бы один вариант")
if not user_can_access_chat(poll.message.chat, user):
raise PollActionError("Нет доступа к этому голосованию", 403)
closed_by_deadline = False
options = []
with transaction.atomic():
locked_poll = (
Poll.objects.select_for_update()
.select_related("message", "message__chat")
.get(pk=poll.pk)
)
if poll_is_effectively_closed(locked_poll):
if not locked_poll.is_closed:
locked_poll.close()
closed_by_deadline = True
else:
if not locked_poll.is_multiple_choice and len(normalized_ids) > 1:
raise PollActionError("Only one option allowed")
options = list(
locked_poll.options.filter(pk__in=normalized_ids).order_by(
"position",
"id",
)
)
if len(options) != len(normalized_ids):
raise PollActionError("Некорректные варианты ответа")
PollVote.objects.filter(poll=locked_poll, voter=user).delete()
PollVote.objects.bulk_create(
[
PollVote(poll=locked_poll, option=option, voter=user)
for option in options
]
)
vote_counts = {
row["option_id"]: row["count"]
for row in (
PollVote.objects.filter(poll=locked_poll)
.values("option_id")
.annotate(count=Count("id"))
)
}
poll_options = list(locked_poll.options.all())
for option in poll_options:
option.vote_count = vote_counts.get(option.id, 0)
PollOption.objects.bulk_update(poll_options, ["vote_count"])
locked_poll.total_voters = (
PollVote.objects.filter(poll=locked_poll)
.values("voter_id")
.distinct()
.count()
)
locked_poll.save(update_fields=["total_voters", "updated_at"])
if closed_by_deadline:
raise PollActionError("Poll is closed")
return locked_poll, [option.id for option in options]

View File

@ -86,6 +86,59 @@ def _get_user_avatar_url(user) -> str:
return ""
def serialize_message_reader(user) -> dict:
"""Краткие данные пользователя для read receipt сообщения."""
return {
"id": user.id,
"name": user.get_full_name() or user.username,
"avatar": _get_user_avatar_url(user),
}
def serialize_poll(poll, user=None) -> dict:
from .polls import poll_is_effectively_closed
user_option_ids = []
if user and getattr(user, "is_authenticated", False):
user_option_ids = list(
poll.votes.filter(voter=user).values_list("option_id", flat=True)
)
total_voters = max(poll.total_voters, 0)
options = []
for option in poll.options.all():
percentage = (
round(option.vote_count / total_voters * 100, 1)
if total_voters
else 0
)
options.append(
{
"id": option.id,
"text": option.text,
"position": option.position,
"vote_count": option.vote_count,
"percentage": percentage,
}
)
return {
"id": poll.id,
"message_id": poll.message_id,
"author_id": poll.author_id,
"question": poll.question,
"is_anonymous": poll.is_anonymous,
"is_multiple_choice": poll.is_multiple_choice,
"is_quiz": poll.is_quiz,
"is_closed": poll_is_effectively_closed(poll),
"closes_at": poll.closes_at.isoformat() if poll.closes_at else None,
"allows_custom_answers": poll.allows_custom_answers,
"total_voters": total_voters,
"user_voted_option_ids": user_option_ids,
"options": options,
}
def _serialize_reply_preview(reply_msg) -> dict:
is_deleted = bool(getattr(reply_msg, "is_deleted", False))
@ -130,13 +183,7 @@ def _get_message_read_by(m) -> list[dict]:
if not user:
continue
readers.append(
{
"id": user.id,
"name": user.get_full_name() or user.username,
"avatar": _get_user_avatar_url(user),
}
)
readers.append(serialize_message_reader(user))
readers.sort(key=lambda item: item["name"].lower())
return readers
@ -298,29 +345,7 @@ def serialize_message(m, user=None, include_linked_tasks=False) -> dict:
# Голосование
if hasattr(m, "poll"):
poll = m.poll
poll_data = {
"id": poll.id,
"question": poll.question,
"is_anonymous": poll.is_anonymous,
"is_multiple_choice": poll.is_multiple_choice,
"is_quiz": poll.is_quiz,
"is_closed": poll.is_closed,
"closes_at": poll.closes_at.isoformat() if poll.closes_at else None,
"total_voters": poll.total_voters,
"options": [],
}
for option in poll.options.all():
poll_data["options"].append(
{
"id": option.id,
"text": option.text,
"position": option.position,
"vote_count": option.vote_count,
"percentage": 0, # Будет пересчитан на клиенте
}
)
data["poll"] = poll_data
data["poll"] = serialize_poll(m.poll, user=user)
# Ответ на сообщение
if m.reply_to_id:

View File

@ -307,6 +307,10 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
"""Обработчик для события poll.update (алиас для chat_poll_update)"""
await self.chat_poll_update(event)
async def poll_update_sync(self, event):
"""Персональная синхронизация выбора в голосовании между сессиями."""
await self.chat_poll_update(event)
async def procurement_update(self, event):
"""
Обновление заявки на закупку.

View File

@ -235,6 +235,66 @@ class TestChatViewSet:
}
]
def test_marked_read_event_includes_reader_details(
self,
monkeypatch,
private_chat,
user2,
):
"""Read receipt содержит данные для обновления списка читателей без API-запроса."""
from communications.api import viewsets as communications_viewsets
sent_events = []
class FakeChannelLayer:
def group_send(self, group, event):
return None
monkeypatch.setattr(
communications_viewsets,
"get_channel_layer",
lambda: FakeChannelLayer(),
)
monkeypatch.setattr(
communications_viewsets,
"async_to_sync",
lambda _callback: lambda group, event: sent_events.append((group, event)),
)
communications_viewsets.ChatViewSet()._send_marked_read_event(
user2,
private_chat.id,
321,
)
expected_reader = {
"id": user2.id,
"name": user2.get_full_name(),
"avatar": "",
}
assert sent_events == [
(
f"user_{user2.id}",
{
"type": "chat_marked_read_sync",
"chat_id": private_chat.id,
"last_read_message_id": 321,
"reader_user_id": user2.id,
"reader": expected_reader,
},
),
(
f"chat_{private_chat.id}",
{
"type": "chat_marked_read",
"chat_id": private_chat.id,
"last_read_message_id": 321,
"reader_user_id": user2.id,
"reader": expected_reader,
},
),
]
def test_mark_read(self, auth_client, private_chat, user1):
"""Пометка чата как прочитанного"""
url = f"/api/v1/communications/chats/{private_chat.pk}/mark-read/"
@ -855,6 +915,117 @@ class TestPollViewSet:
assert "options" in response.data
assert response.data["total_voters"] == 1
def test_create_poll_returns_message_payload(
self,
auth_client,
private_chat,
):
response = auth_client.post(
"/api/v1/communications/polls/",
{
"chat_id": private_chat.id,
"question": "Где обедаем?",
"options_data": ["В офисе", "В кафе"],
"is_anonymous": False,
"is_multiple_choice": False,
},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["poll"]["question"] == "Где обедаем?"
assert response.data["message"]["poll"]["options"] == [
{
"id": response.data["poll"]["options"][0]["id"],
"text": "В офисе",
"position": 0,
"vote_count": 0,
"percentage": 0,
},
{
"id": response.data["poll"]["options"][1]["id"],
"text": "В кафе",
"position": 1,
"vote_count": 0,
"percentage": 0,
},
]
def test_create_poll_rejects_duplicate_options(
self,
auth_client,
private_chat,
):
response = auth_client.post(
"/api/v1/communications/polls/",
{
"chat_id": private_chat.id,
"question": "Повтор?",
"options_data": ["Да", " да "],
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "options_data" in response.data
def test_poll_is_hidden_from_user_without_chat_access(
self,
user3,
poll_message,
):
_message, poll, option, _option2 = poll_message
client = APIClient()
client.force_authenticate(user=user3)
response = client.post(
f"/api/v1/communications/polls/{poll.id}/vote/",
{"option_ids": [option.id]},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_poll_deadline_closes_voting(
self,
auth_client,
poll_message,
):
from datetime import timedelta
_message, poll, option, _option2 = poll_message
poll.closes_at = timezone.now() - timedelta(seconds=1)
poll.save(update_fields=["closes_at"])
response = auth_client.post(
f"/api/v1/communications/polls/{poll.id}/vote/",
{"option_ids": [option.id]},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
poll.refresh_from_db()
assert poll.is_closed is True
def test_poll_does_not_expose_generic_update_or_delete(
self,
auth_client,
poll_message,
):
_message, poll, _option, _option2 = poll_message
patch_response = auth_client.patch(
f"/api/v1/communications/polls/{poll.id}/",
{"question": "Измененный вопрос"},
format="json",
)
delete_response = auth_client.delete(
f"/api/v1/communications/polls/{poll.id}/"
)
assert patch_response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
assert delete_response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
# ==================== Integration Tests ====================

View File

@ -479,6 +479,83 @@ class TestPollWebSocketEvents:
with pytest.raises(asyncio.TimeoutError):
await comm.receive_json_from(timeout=2)
@pytest.mark.asyncio
async def test_vote_poll_action_updates_all_active_participants(
self,
ws_communicator,
user,
user2,
test_chat_with_users,
):
def create_poll():
from communications.models import Message, Poll, PollOption
message = Message.objects.create(
chat=test_chat_with_users,
author=user2,
content="Выберите вариант",
)
poll = Poll.objects.create(
message=message,
author=user2,
question="Выберите вариант",
)
option = PollOption.objects.create(
poll=poll,
text="Первый",
position=0,
)
PollOption.objects.create(
poll=poll,
text="Второй",
position=1,
)
return poll.id, message.id, option.id
poll_id, message_id, option_id = await database_sync_to_async(create_poll)()
comm_voter = await ws_communicator(user=user)
comm_other = await ws_communicator(user=user2)
assert (await comm_voter.connect())[0]
assert (await comm_other.connect())[0]
for communicator in (comm_voter, comm_other):
await communicator.send_json_to(
{
"action": "open_chat",
"chat_id": test_chat_with_users.id,
"load_history": False,
}
)
opened = await communicator.receive_json_from(timeout=5)
assert opened["type"] == "chat_opened"
await comm_voter.send_json_to(
{
"action": "vote_poll",
"poll_id": poll_id,
"option_ids": [option_id],
}
)
voter_public_update = await comm_voter.receive_json_from(timeout=5)
voter_update = await comm_voter.receive_json_from(timeout=5)
other_update = await comm_other.receive_json_from(timeout=5)
for update in (voter_public_update, other_update):
assert update["type"] == "poll_update"
assert update["message_id"] == message_id
assert update["voter_user_id"] is None
assert update["selected_option_ids"] == []
assert update["results"]["total_voters"] == 1
assert update["results"]["options"][0]["vote_count"] == 1
assert voter_update["type"] == "poll_update"
assert voter_update["message_id"] == message_id
assert voter_update["voter_user_id"] == user.id
assert voter_update["selected_option_ids"] == [option_id]
await comm_voter.disconnect()
await comm_other.disconnect()
class TestChatMarkedReadEvent:
"""Тесты WebSocket события отметки прочтения."""
@ -517,6 +594,11 @@ class TestChatMarkedReadEvent:
# Отправляем sync-событие в пользовательский канал (как делает API)
channel_layer = get_channel_layer()
mock_message_id = 988
reader = {
"id": user.id,
"name": user.get_full_name() or user.username,
"avatar": "",
}
await channel_layer.group_send(
f'user_{user.id}',
@ -525,6 +607,7 @@ class TestChatMarkedReadEvent:
'chat_id': test_chat.id,
'last_read_message_id': mock_message_id,
'reader_user_id': user.id,
'reader': reader,
}
)
@ -534,12 +617,14 @@ class TestChatMarkedReadEvent:
assert response1["chat_id"] == test_chat.id
assert response1["last_read_message_id"] == mock_message_id
assert response1["reader_user_id"] == user.id
assert response1["reader"] == reader
response2 = await comm2.receive_json_from(timeout=5)
assert response2["type"] == "marked_read"
assert response2["chat_id"] == test_chat.id
assert response2["last_read_message_id"] == mock_message_id
assert response2["reader_user_id"] == user.id
assert response2["reader"] == reader
await comm1.disconnect()
await comm2.disconnect()
@ -585,6 +670,11 @@ class TestChatMarkedReadEvent:
# Отправляем read receipt в канал чата (как делает API для других участников)
channel_layer = get_channel_layer()
mock_message_id = 987
reader = {
"id": user.id,
"name": user.get_full_name() or user.username,
"avatar": "",
}
await channel_layer.group_send(
f'chat_{test_chat_with_users.id}',
@ -593,6 +683,7 @@ class TestChatMarkedReadEvent:
'chat_id': test_chat_with_users.id,
'last_read_message_id': mock_message_id,
'reader_user_id': user.id,
'reader': reader,
}
)
@ -602,6 +693,7 @@ class TestChatMarkedReadEvent:
assert response_other["chat_id"] == test_chat_with_users.id
assert response_other["last_read_message_id"] == mock_message_id
assert response_other["reader_user_id"] == user.id
assert response_other["reader"] == reader
# Сам читатель не должен получать receipt из chat-группы
with pytest.raises(asyncio.TimeoutError):

View File

@ -15,6 +15,7 @@ import ChatDialogHeader from "@/components/messages/ChatDialogHeader";
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem";
import ChatSearchPanel from "@/components/messages/ChatSearchPanel";
import CreatePollModal from "@/components/messages/CreatePollModal";
import MessageReadersModal from "@/components/messages/MessageReadersModal";
import MessageComposer from "@/components/messages/MessageComposer";
import ReactionPickerModal from "@/components/messages/ReactionPickerModal";
@ -23,6 +24,7 @@ import { getTaskColumnDisplayName, getTaskDestinationColumns } from "@/lib/tasks
import type {
ChatMessageSearchResponse,
ChatMessageSearchResult,
CreatePollPayload,
Message,
TaskBoard,
TaskCard,
@ -39,6 +41,8 @@ import { markChatReadLocally } from "@/hooks/useMessagesChatList";
import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll";
import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils";
import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils";
import { applyMessageReadReceipt } from "@/lib/messages/messageReadState.js";
import { applyPollUpdateToMessages } from "@/lib/messages/pollState.js";
import { displayUserName } from "@/lib/shared";
import wsManager from "@/lib/websocketManager";
import ScrollableMessageList, { ScrollableMessageListInner } from "@/components/ScrollableMessageList";
@ -238,6 +242,7 @@ function MessageDialogPageContent() {
const [isReadersModalOpen, setIsReadersModalOpen] = useState(false);
const [reactionPickerForMessageId, setReactionPickerForMessageId] = useState<number | null>(null);
const [showComposerEmojiPicker, setShowComposerEmojiPicker] = useState(false);
const [isCreatePollOpen, setIsCreatePollOpen] = useState(false);
const [recentReactions, setRecentReactions] = useState<string[]>(ALL_REACTIONS.slice(0, MAX_RECENT_REACTIONS));
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const [brokenMedia, setBrokenMedia] = useState<Record<number, boolean>>({});
@ -482,18 +487,24 @@ function MessageDialogPageContent() {
if (data.reactions_summary && typeof data.message_id === "number") {
updateMessageReactionsSummary(data.message_id, data.reactions_summary);
}
} else if (data.type === "poll_update" && data.results && typeof data.message_id === "number") {
const pollMessageId = data.message_id;
const pollResults = data.results;
cm.setMessages(prev => applyPollUpdateToMessages(
prev,
pollMessageId,
pollResults,
user?.id,
data.voter_user_id,
data.selected_option_ids || [],
));
} else if (data.type === "marked_read" && data.chat_id === chatId && typeof data.last_read_message_id === "number") {
const lastReadId = data.last_read_message_id;
const readerId = typeof data.reader_user_id === "number" ? data.reader_user_id : null;
const reader = data.reader;
if (readerId !== null) {
cm.setMessages(prev =>
prev.map(m => {
const aId = m.author_id ?? m.author?.id ?? m.sender?.id;
if (m.id > lastReadId || aId === readerId || m.is_read) return m;
return { ...m, is_read: true };
}),
);
if (reader && readerId !== null && reader.id === readerId) {
cm.setMessages(prev => applyMessageReadReceipt(prev, lastReadId, reader));
}
if (readerId === null || readerId === user?.id) {
@ -733,6 +744,46 @@ function MessageDialogPageContent() {
const removeAttachedFile = (index: number) => setAttachedFiles(prev => prev.filter((_, i) => i !== index));
const handlePickFiles = () => { if (!editingMessageId) fileInputRef.current?.click(); };
const handleCreatePoll = async (payload: CreatePollPayload) => {
const wasNearBottom = scroll.isNearBottom();
const response = await apiClient.createPoll(payload);
cm.setMessages(prev => uniqueMessagesById([...prev, response.message]));
setReplyTo(null);
if (wasNearBottom) {
requestAnimationFrame(() => scroll.scrollToBottom(false));
}
};
const handleVotePoll = async (pollId: number, optionIds: number[]) => {
const response = await apiClient.votePoll(pollId, optionIds);
cm.setMessages(prev => {
const message = prev.find(item => item.poll?.id === pollId);
if (!message) return prev;
return applyPollUpdateToMessages(
prev,
message.id,
response.results,
user?.id,
user?.id,
response.results.user_voted_option_ids,
);
});
};
const handleClosePoll = async (pollId: number) => {
const poll = await apiClient.closePoll(pollId);
cm.setMessages(prev => {
const message = prev.find(item => item.poll?.id === pollId);
if (!message) return prev;
return applyPollUpdateToMessages(
prev,
message.id,
poll,
user?.id,
);
});
};
const canRetryMessage = useCallback((message: Message): boolean => {
if (message.send_state !== "failed" || !message.local_id) return false;
const hasAttachments = Boolean(message.attachments?.length);
@ -1479,6 +1530,8 @@ function MessageDialogPageContent() {
onShowAllReaders={() => setIsReadersModalOpen(true)}
onLinkToTask={handleOpenTaskLinkModal}
onRetry={handleRetryMessage}
onVotePoll={handleVotePoll}
onClosePoll={handleClosePoll}
isRetrying={Boolean(message.local_id && retryingLocalIds.has(message.local_id))}
retryDisabled={!canRetryMessage(message)}
onReply={handleReplyToMessage}
@ -1539,6 +1592,10 @@ function MessageDialogPageContent() {
fileInputRef={fileInputRef}
messageInputRef={messageInputRef}
onPickFiles={handlePickFiles}
onCreatePoll={() => {
setShowComposerEmojiPicker(false);
setIsCreatePollOpen(true);
}}
onAddFiles={addAttachedFiles}
onFilesChange={handleFilesChange}
onRemoveFile={removeAttachedFile}
@ -1578,6 +1635,14 @@ function MessageDialogPageContent() {
<MessageReadersModal isOpen={isReadersModalOpen} onClose={() => setIsReadersModalOpen(false)} readers={selectedActionReaders} />
) : null}
<CreatePollModal
isOpen={isCreatePollOpen}
chatId={chatId}
replyToId={replyTo?.id}
onClose={() => setIsCreatePollOpen(false)}
onCreate={handleCreatePoll}
/>
<Modal
isOpen={Boolean(taskLinkMessage)}
onClose={handleCloseTaskLinkModal}

View File

@ -17,6 +17,7 @@ import {
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import MessageActionsMenu from "@/components/messages/MessageActionsMenu";
import MessagePollCard from "@/components/messages/MessagePollCard";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import { resolveMediaUrl } from "@/lib/url";
import {
@ -77,6 +78,8 @@ type ChatMessageItemProps = {
onShowAllReaders?: (message: Message) => void;
onLinkToTask?: (message: Message) => void;
onRetry?: (message: Message) => void;
onVotePoll: (pollId: number, optionIds: number[]) => Promise<void>;
onClosePoll: (pollId: number) => Promise<void>;
isRetrying?: boolean;
retryDisabled?: boolean;
onReply: (message: Message) => void;
@ -157,6 +160,8 @@ export default function ChatMessageItem({
onShowAllReaders,
onLinkToTask,
onRetry,
onVotePoll,
onClosePoll,
isRetrying = false,
retryDisabled = false,
onReply,
@ -237,7 +242,9 @@ export default function ChatMessageItem({
type="button"
data-actions-trigger="true"
onClick={() => onToggleActions(message.id)}
className={`inline-flex h-6 w-6 items-center justify-center rounded-full border border-transparent bg-transparent text-[var(--muted-foreground)] transition hover:text-sky-600 ${
className={`inline-flex h-6 w-6 items-center justify-center rounded-full border border-transparent bg-transparent transition ${
isMine ? "text-white/80 hover:text-white" : "text-[var(--muted-foreground)] hover:text-sky-600"
} ${
isActionsOpen ? "rotate-90" : ""
}`}
title="Действия"
@ -301,12 +308,22 @@ export default function ChatMessageItem({
{message.is_deleted ? (
<p className={`italic text-sm ${isMine ? "text-sky-100" : "app-text-muted"}`}>Сообщение удалено</p>
) : message.content ? (
) : message.content && !message.poll ? (
<p className="whitespace-pre-wrap break-words text-sm leading-5 [overflow-wrap:anywhere]">
{message.content}
</p>
) : null}
{!message.is_deleted && message.poll ? (
<MessagePollCard
poll={message.poll}
isMine={isMine}
canClose={Boolean(currentUserId && message.poll.author_id === currentUserId)}
onVote={onVotePoll}
onClose={onClosePoll}
/>
) : null}
{message.attachments && message.attachments.length > 0 ? (
<div className="mt-2 space-y-2">
{message.attachments.map((attachment) => {

View File

@ -0,0 +1,231 @@
"use client";
import { useState } from "react";
import { Clock3, Plus, Trash2 } from "lucide-react";
import { Modal } from "@/components/ui";
import type { CreatePollPayload } from "@/types/api";
type CreatePollModalProps = {
isOpen: boolean;
chatId: number;
replyToId?: number;
onClose: () => void;
onCreate: (payload: CreatePollPayload) => Promise<void>;
};
const closeOptions = [
{ value: "", label: "Не ограничивать" },
{ value: "60", label: "Через 1 час" },
{ value: "1440", label: "Через 1 день" },
{ value: "10080", label: "Через 7 дней" },
];
function PollToggle({
checked,
onChange,
label,
description,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
description: string;
}) {
return (
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3 transition hover:border-[var(--border-strong)] sm:px-4">
<span className="min-w-0">
<span className="block text-sm font-medium text-[var(--foreground)]">{label}</span>
<span className="app-text-muted mt-0.5 block text-xs leading-4">{description}</span>
</span>
<span className="relative inline-flex shrink-0 items-center">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="peer sr-only"
/>
<span className="h-6 w-11 rounded-full bg-[var(--surface-tertiary)] transition after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm after:transition-all after:content-[''] peer-checked:bg-[var(--accent-primary)] peer-checked:after:translate-x-full peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--accent-primary)] peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-[var(--surface-primary)]" />
</span>
</label>
);
}
export default function CreatePollModal({
isOpen,
chatId,
replyToId,
onClose,
onCreate,
}: CreatePollModalProps) {
const [question, setQuestion] = useState("");
const [options, setOptions] = useState(["", ""]);
const [multiple, setMultiple] = useState(false);
const [anonymous, setAnonymous] = useState(false);
const [closesIn, setClosesIn] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const reset = () => {
setQuestion("");
setOptions(["", ""]);
setMultiple(false);
setAnonymous(false);
setClosesIn("");
setError(null);
};
const close = () => {
if (submitting) return;
reset();
onClose();
};
const canSubmit = question.trim().length > 0
&& options.length >= 2
&& options.every((option) => option.trim().length > 0);
const submit = async () => {
if (!canSubmit || submitting) return;
setSubmitting(true);
setError(null);
try {
await onCreate({
chat_id: chatId,
question: question.trim(),
options_data: options.map((option) => option.trim()),
is_anonymous: anonymous,
is_multiple_choice: multiple,
closes_in_minutes: closesIn ? Number(closesIn) : null,
reply_to_id: replyToId,
});
reset();
onClose();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Не удалось создать голосование");
} finally {
setSubmitting(false);
}
};
const footer = (
<div className="flex justify-end gap-2">
<button type="button" onClick={close} disabled={submitting} className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium">
Отмена
</button>
<button type="button" onClick={submit} disabled={!canSubmit || submitting} className="app-action-primary rounded-lg px-4 py-2.5 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? "Создаем..." : "Создать"}
</button>
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={close}
title="Новое голосование"
size="md"
className="h-[100dvh] max-w-full rounded-none sm:h-auto sm:rounded-2xl"
footer={footer}
closeOnEsc={!submitting}
>
<div className="space-y-4 pb-2">
{error ? <p className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{error}</p> : null}
<section className="app-surface-muted space-y-4 rounded-xl p-3 sm:p-4">
<label className="block">
<span className="app-text-muted mb-1.5 flex items-center justify-between gap-2 text-xs font-medium">
<span>Вопрос <span className="app-accent-text">*</span></span>
<span className="font-normal">{question.length}/500</span>
</span>
<textarea
autoFocus
value={question}
onChange={(event) => setQuestion(event.target.value)}
maxLength={500}
rows={3}
className="app-input min-h-24 w-full resize-none rounded-lg px-4 py-3 text-sm leading-5"
placeholder="О чем хотите спросить?"
/>
</label>
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="app-text-muted text-xs font-medium">Варианты ответа <span className="app-accent-text">*</span></span>
<span className="app-text-muted text-[11px]">{options.length}/10</span>
</div>
<div className="space-y-2">
{options.map((option, index) => (
<div key={index} className="relative">
<input
value={option}
onChange={(event) => setOptions((current) => current.map((item, itemIndex) => itemIndex === index ? event.target.value : item))}
maxLength={200}
className="app-input w-full rounded-lg py-3 pl-4 pr-12 text-sm"
placeholder={`Вариант ${index + 1}`}
/>
{options.length > 2 ? (
<button
type="button"
onClick={() => setOptions((current) => current.filter((_, itemIndex) => itemIndex !== index))}
className="app-icon-button absolute right-2 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-lg text-[var(--muted-foreground)] hover:text-rose-500"
title="Удалить вариант"
aria-label={`Удалить вариант ${index + 1}`}
>
<Trash2 size={15} />
</button>
) : null}
</div>
))}
</div>
{options.length < 10 ? (
<button
type="button"
onClick={() => setOptions((current) => [...current, ""])}
className="app-link-accent mt-3 inline-flex items-center gap-1.5 text-xs font-medium"
>
<Plus size={14} /> Добавить вариант
</button>
) : null}
</div>
</section>
<section className="app-surface-muted space-y-3 rounded-xl p-3 sm:p-4">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">Настройки</p>
<p className="app-text-muted mt-0.5 text-xs">Настройки можно оставить по умолчанию.</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<PollToggle
checked={multiple}
onChange={setMultiple}
label="Несколько вариантов"
description="Можно выбрать больше одного ответа"
/>
<PollToggle
checked={anonymous}
onChange={setAnonymous}
label="Анонимное голосование"
description="Имена участников будут скрыты"
/>
</div>
<label className="block">
<span className="app-text-muted mb-1.5 block text-xs font-medium">Завершение</span>
<span className="relative block">
<Clock3 size={15} className="app-text-muted pointer-events-none absolute left-4 top-1/2 -translate-y-1/2" />
<select
value={closesIn}
onChange={(event) => setClosesIn(event.target.value)}
className="app-select w-full rounded-lg py-3 pl-10 pr-10 text-sm"
>
{closeOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</span>
</label>
</section>
</div>
</Modal>
);
}

View File

@ -1,8 +1,8 @@
"use client";
import { useLayoutEffect, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type React from "react";
import { Paperclip, Send, Smile, X } from "lucide-react";
import { BarChart3, FileText, Paperclip, Send, Smile, X } from "lucide-react";
const MIN_COMPOSER_HEIGHT = 38;
const MAX_COMPOSER_HEIGHT = 128;
@ -26,6 +26,7 @@ type MessageComposerProps = {
fileInputRef: React.RefObject<HTMLInputElement | null>;
messageInputRef: React.RefObject<HTMLTextAreaElement | null>;
onPickFiles: () => void;
onCreatePoll: () => void;
onAddFiles: (files: File[]) => void;
onFilesChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onRemoveFile: (index: number) => void;
@ -52,6 +53,7 @@ export default function MessageComposer({
fileInputRef,
messageInputRef,
onPickFiles,
onCreatePoll,
onAddFiles,
onFilesChange,
onRemoveFile,
@ -65,8 +67,22 @@ export default function MessageComposer({
onCancelReply,
}: MessageComposerProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [showAttachmentMenu, setShowAttachmentMenu] = useState(false);
const attachmentMenuRef = useRef<HTMLDivElement | null>(null);
const isAttachmentMenuOpen = showAttachmentMenu && !editingMessageId;
const canSend = editingMessageId ? messageText.trim().length > 0 : messageText.trim().length > 0 || attachedFiles.length > 0;
useEffect(() => {
if (!isAttachmentMenuOpen) return;
const closeMenu = (event: PointerEvent) => {
if (!attachmentMenuRef.current?.contains(event.target as Node)) {
setShowAttachmentMenu(false);
}
};
document.addEventListener("pointerdown", closeMenu);
return () => document.removeEventListener("pointerdown", closeMenu);
}, [isAttachmentMenuOpen]);
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (editingMessageId) return;
@ -166,27 +182,61 @@ export default function MessageComposer({
onChange={onFilesChange}
/>
<div
className={`app-input relative min-w-0 flex-1 ${showEmojiPicker ? "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 ? "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"
>
<button
type="button"
onClick={onPickFiles}
disabled={Boolean(editingMessageId)}
className={`app-icon-button absolute left-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:opacity-50 ${
isExpanded ? "bottom-2.5" : "top-1/2 -translate-y-1/2"
}`}
title={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
aria-label={editingMessageId ? "При редактировании вложения недоступны" : "Добавить файлы"}
>
<Paperclip size={14} />
</button>
<div ref={attachmentMenuRef} className={`absolute left-3 z-20 ${isExpanded ? "bottom-2.5" : "top-1/2 -translate-y-1/2"}`}>
<button
type="button"
onClick={() => {
setShowAttachmentMenu((current) => !current);
if (showEmojiPicker) onToggleEmojiPicker();
}}
disabled={Boolean(editingMessageId)}
className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:opacity-50"
title={editingMessageId ? "При редактировании вложения недоступны" : "Прикрепить"}
aria-label={editingMessageId ? "При редактировании вложения недоступны" : "Выбрать вложение"}
aria-expanded={isAttachmentMenuOpen}
>
<Paperclip size={14} />
</button>
{isAttachmentMenuOpen ? (
<div className="app-menu absolute bottom-full left-0 z-30 mb-2 w-48 rounded-lg py-1.5">
<button
type="button"
onClick={() => {
setShowAttachmentMenu(false);
onPickFiles();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<FileText size={15} className="app-text-muted" />
Файл
</button>
<button
type="button"
onClick={() => {
setShowAttachmentMenu(false);
onCreatePoll();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<BarChart3 size={15} className="app-text-muted" />
Голосование
</button>
</div>
) : null}
</div>
<button
type="button"
onClick={onToggleEmojiPicker}
onClick={() => {
setShowAttachmentMenu(false);
onToggleEmojiPicker();
}}
className={`app-icon-button absolute right-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-sky-100 ${
isExpanded ? "bottom-2.5" : "top-1/2 -translate-y-1/2"
}`}
@ -221,7 +271,10 @@ export default function MessageComposer({
onChangeMessage(event.target.value);
onTyping();
}}
onClick={onInputClick}
onClick={() => {
setShowAttachmentMenu(false);
onInputClick();
}}
onPaste={handlePaste}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {

View File

@ -0,0 +1,207 @@
"use client";
import { useEffect, useState } from "react";
import { BarChart3, Check, CheckCircle2, Clock3, Lock } from "lucide-react";
import type { MessagePoll } from "@/types/api";
type MessagePollCardProps = {
poll: MessagePoll;
isMine: boolean;
canClose: boolean;
onVote: (pollId: number, optionIds: number[]) => Promise<void>;
onClose: (pollId: number) => Promise<void>;
};
function votersLabel(count: number): string {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} участник`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} участника`;
}
return `${count} участников`;
}
function deadlineLabel(value?: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export default function MessagePollCard({
poll,
isMine,
canClose,
onVote,
onClose,
}: MessagePollCardProps) {
const [selectedIds, setSelectedIds] = useState<number[]>(poll.user_voted_option_ids || []);
const [submitting, setSubmitting] = useState(false);
const [closing, setClosing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deadlineExpired, setDeadlineExpired] = useState(false);
const hasVoted = (poll.user_voted_option_ids || []).length > 0;
const isClosed = poll.is_closed || deadlineExpired;
const showResults = hasVoted || isClosed;
const deadline = deadlineLabel(poll.closes_at);
useEffect(() => {
setSelectedIds(poll.user_voted_option_ids || []);
}, [poll.user_voted_option_ids]);
useEffect(() => {
if (!poll.closes_at || poll.is_closed) {
setDeadlineExpired(false);
return;
}
const expiresAt = new Date(poll.closes_at).getTime();
if (!Number.isFinite(expiresAt)) {
setDeadlineExpired(false);
return;
}
const updateDeadline = () => setDeadlineExpired(Date.now() >= expiresAt);
updateDeadline();
const remaining = expiresAt - Date.now();
if (remaining <= 0) return;
const timeout = window.setTimeout(updateDeadline, Math.min(remaining + 50, 2_147_483_647));
return () => window.clearTimeout(timeout);
}, [poll.closes_at, poll.is_closed]);
const toggleOption = (optionId: number) => {
if (isClosed || submitting) return;
setError(null);
setSelectedIds((current) => {
if (!poll.is_multiple_choice) return [optionId];
return current.includes(optionId)
? current.filter((id) => id !== optionId)
: [...current, optionId];
});
};
const submitVote = async () => {
if (selectedIds.length === 0 || submitting) return;
setSubmitting(true);
setError(null);
try {
await onVote(poll.id, selectedIds);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Не удалось сохранить голос");
} finally {
setSubmitting(false);
}
};
const closePoll = async () => {
if (closing) return;
setClosing(true);
setError(null);
try {
await onClose(poll.id);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Не удалось завершить голосование");
} finally {
setClosing(false);
}
};
return (
<div className="min-w-[15rem] max-w-[28rem] py-0.5">
<div className="mb-2 flex items-start gap-2 pr-1">
<BarChart3 size={17} className={`mt-0.5 shrink-0 ${isMine ? "text-white" : "text-sky-500"}`} />
<div className="min-w-0">
<p className="whitespace-pre-wrap break-words text-sm font-semibold leading-5 [overflow-wrap:anywhere]">
{poll.question}
</p>
<p className={`mt-0.5 text-[11px] ${isMine ? "text-sky-100" : "app-text-muted"}`}>
{poll.is_multiple_choice ? "Можно выбрать несколько вариантов" : "Выберите один вариант"}
{poll.is_anonymous ? " · Анонимно" : ""}
</p>
</div>
</div>
<div className="space-y-1.5">
{poll.options.map((option) => {
const selected = selectedIds.includes(option.id);
const voted = poll.user_voted_option_ids?.includes(option.id);
return (
<button
key={option.id}
type="button"
disabled={isClosed || submitting}
onClick={() => toggleOption(option.id)}
className={`relative flex min-h-10 w-full items-center gap-2 overflow-hidden rounded-lg border px-3 py-2 text-left text-sm transition disabled:cursor-default ${
isMine
? "border-white/25 bg-white/10 hover:bg-white/15"
: "border-[var(--border-subtle)] bg-[var(--surface-secondary)] hover:bg-[var(--surface-tertiary)]"
} ${selected ? (isMine ? "ring-2 ring-white/70" : "border-sky-400 ring-1 ring-sky-300") : ""}`}
>
{showResults ? (
<span
className={`absolute inset-y-0 left-0 ${isMine ? "bg-white/15" : "bg-sky-500/12"}`}
style={{ width: `${Math.min(option.percentage, 100)}%` }}
/>
) : null}
<span className={`relative inline-flex h-4 w-4 shrink-0 items-center justify-center border ${
poll.is_multiple_choice ? "rounded" : "rounded-full"
} ${selected ? (isMine ? "border-white bg-white text-sky-600" : "border-sky-500 bg-sky-500 text-white") : (isMine ? "border-white/60" : "border-[var(--border-strong)]")}`}>
{selected ? <Check size={11} strokeWidth={3} /> : null}
</span>
<span className="relative min-w-0 flex-1 break-words">{option.text}</span>
{showResults ? (
<span className={`relative shrink-0 text-xs font-semibold ${isMine ? "text-white" : "text-[var(--foreground)]"}`}>
{option.percentage}%
</span>
) : null}
{voted && showResults ? <CheckCircle2 size={13} className="relative shrink-0" /> : null}
</button>
);
})}
</div>
{error ? <p className={`mt-2 text-xs ${isMine ? "text-rose-100" : "text-rose-500"}`}>{error}</p> : null}
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<div className={`flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] ${isMine ? "text-sky-100" : "app-text-muted"}`}>
<span>{votersLabel(poll.total_voters)}</span>
{deadline ? <span className="inline-flex items-center gap-1"><Clock3 size={11} />До {deadline}</span> : null}
{isClosed ? <span className="inline-flex items-center gap-1"><Lock size={11} />Завершено</span> : null}
</div>
{!isClosed ? (
<div className="ml-auto flex items-center gap-2">
{canClose ? (
<button
type="button"
disabled={closing}
onClick={closePoll}
className={`text-xs font-medium transition disabled:opacity-50 ${isMine ? "text-sky-100 hover:text-white" : "app-text-muted hover:text-[var(--foreground)]"}`}
>
{closing ? "Завершаем..." : "Завершить"}
</button>
) : null}
<button
type="button"
disabled={selectedIds.length === 0 || submitting}
onClick={submitVote}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50 ${
isMine ? "bg-white text-sky-600 hover:bg-sky-50" : "bg-sky-500 text-white hover:bg-sky-600"
}`}
>
{submitting ? "Сохраняем..." : hasVoted ? "Изменить голос" : "Голосовать"}
</button>
</div>
) : null}
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { getWebSocketUrl } from '@/lib/url';
import type { Message } from '@/types/api';
import type { Message, MessagePoll, MessageReader } from '@/types/api';
import { AUTH_SESSION_EVENT, type AuthSessionEventDetail } from '@/lib/api/client';
type ReactionsSummary = Record<string, { count: number; users?: number[]; user_names?: string[] }>;
@ -14,6 +14,11 @@ interface WebSocketMessage {
chat_id?: number;
last_read_message_id?: number;
reader_user_id?: number;
reader?: MessageReader;
poll_id?: number;
results?: MessagePoll;
voter_user_id?: number;
selected_option_ids?: number[];
reactions_summary?: ReactionsSummary;
[key: string]: unknown;
}

View File

@ -1,5 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { ChatMessageSearchResponse } from '@/types/api';
import type {
ChatMessageSearchResponse,
CreatePollPayload,
CreatePollResponse,
MessagePoll,
PollVoteResponse,
} from '@/types/api';
import type { RequestFn } from './utils';
export function createMessagesApi(request: RequestFn) {
@ -108,5 +114,8 @@ export function createMessagesApi(request: RequestFn) {
updateChatUserSettings: (chatId: number, data: Record<string, any>) => request(`/api/v1/communications/chats/${chatId}/user-settings/`, { method: 'PATCH', body: JSON.stringify(data) }),
reactToMessage: (messageId: number, emoji: string) => request(`/api/v1/communications/messages/${messageId}/react/`, { method: 'POST', body: JSON.stringify({ emoji }) }),
unreactToMessage: (messageId: number, emoji: string) => request(`/api/v1/communications/messages/${messageId}/unreact/`, { method: 'POST', body: JSON.stringify({ emoji }) }),
createPoll: (data: CreatePollPayload): Promise<CreatePollResponse> => request('/api/v1/communications/polls/', { method: 'POST', body: JSON.stringify(data) }),
votePoll: (pollId: number, optionIds: number[]): Promise<PollVoteResponse> => request(`/api/v1/communications/polls/${pollId}/vote/`, { method: 'POST', body: JSON.stringify({ option_ids: optionIds }) }),
closePoll: (pollId: number): Promise<MessagePoll> => request(`/api/v1/communications/polls/${pollId}/close/`, { method: 'POST' }),
};
}

View File

@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyMessageReadReceipt } from "./messages/messageReadState.js";
const reader = { id: 8, name: "Reader User", avatar: "/avatar.jpg" };
test("read receipt adds the reader to eligible messages up to the watermark", () => {
const messages = [
{ id: 10, author_id: 3, content: "First" },
{ id: 11, author_id: 4, content: "Second", is_read: true, read_by: [{ id: 6, name: "Earlier Reader" }] },
{ id: 12, author_id: 3, content: "Later" },
];
const result = applyMessageReadReceipt(messages, 11, reader);
assert.deepEqual(result[0].read_by, [reader]);
assert.equal(result[0].read_count, 1);
assert.equal(result[0].is_read, true);
assert.deepEqual(result[1].read_by, [
{ id: 6, name: "Earlier Reader" },
reader,
]);
assert.equal(result[1].read_count, 2);
assert.equal(result[2], messages[2]);
});
test("read receipt excludes the reader's own messages and is idempotent", () => {
const messages = [
{ id: 10, author_id: reader.id, content: "Own" },
{ id: 11, author_id: 3, content: "Read", read_by: [reader], read_count: 1 },
];
const result = applyMessageReadReceipt(messages, 11, reader);
assert.equal(result, messages);
assert.equal(result[0].read_by, undefined);
assert.equal(result[1].read_count, 1);
});

View File

@ -0,0 +1,7 @@
import type { Message, MessageReader } from "@/types/api";
export function applyMessageReadReceipt(
messages: Message[],
lastReadMessageId: number,
reader: MessageReader,
): Message[];

View File

@ -0,0 +1,34 @@
function getAuthorId(message) {
return message.author_id ?? message.author?.id ?? message.sender?.id ?? null;
}
export function applyMessageReadReceipt(messages, lastReadMessageId, reader) {
if (!reader || !Number.isFinite(reader.id) || !Number.isFinite(lastReadMessageId)) {
return messages;
}
let changed = false;
const nextMessages = messages.map((message) => {
if (message.id > lastReadMessageId || getAuthorId(message) === reader.id) {
return message;
}
const readers = message.read_by ?? [];
if (readers.some((item) => item.id === reader.id)) {
return message;
}
const readBy = [...readers, reader].sort((left, right) =>
left.name.localeCompare(right.name),
);
changed = true;
return {
...message,
is_read: true,
read_by: readBy,
read_count: readBy.length,
};
});
return changed ? nextMessages : messages;
}

View File

@ -0,0 +1,10 @@
import type { Message, MessagePoll } from "@/types/api";
export function applyPollUpdateToMessages(
messages: Message[],
messageId: number,
poll: MessagePoll,
currentUserId?: number,
voterUserId?: number,
selectedOptionIds?: number[],
): Message[];

View File

@ -0,0 +1,29 @@
export function applyPollUpdateToMessages(
messages,
messageId,
poll,
currentUserId,
voterUserId,
selectedOptionIds,
) {
let changed = false;
const nextMessages = messages.map((message) => {
if (message.id !== messageId || !message.poll || message.poll.id !== poll.id) {
return message;
}
changed = true;
return {
...message,
poll: {
...message.poll,
...poll,
user_voted_option_ids: voterUserId === currentUserId
? selectedOptionIds
: (message.poll.user_voted_option_ids ?? []),
},
};
});
return changed ? nextMessages : messages;
}

View File

@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyPollUpdateToMessages } from "./messages/pollState.js";
const poll = {
id: 4,
total_voters: 2,
options: [
{ id: 10, vote_count: 1, percentage: 50 },
{ id: 11, vote_count: 1, percentage: 50 },
],
user_voted_option_ids: [],
};
test("poll updates keep another viewer's personal selection", () => {
const messages = [{
id: 7,
poll: { ...poll, total_voters: 1, user_voted_option_ids: [10] },
}];
const result = applyPollUpdateToMessages(messages, 7, poll, 1, 2, [11]);
assert.equal(result[0].poll.total_voters, 2);
assert.deepEqual(result[0].poll.user_voted_option_ids, [10]);
});
test("poll updates synchronize the voter selection across their tabs", () => {
const messages = [{
id: 7,
poll: { ...poll, total_voters: 1, user_voted_option_ids: [10] },
}];
const result = applyPollUpdateToMessages(messages, 7, poll, 2, 2, [11]);
assert.deepEqual(result[0].poll.user_voted_option_ids, [11]);
});

View File

@ -1556,6 +1556,7 @@ export interface Message {
}>;
}>;
linked_tasks?: MessageLinkedTask[];
poll?: MessagePoll;
}
export interface MessageReplyPreview {
@ -1572,6 +1573,50 @@ export interface MessageReader {
avatar?: string;
}
export interface PollOption {
id: number;
text: string;
position: number;
vote_count: number;
percentage: number;
}
export interface MessagePoll {
id: number;
message_id: number;
author_id: number;
question: string;
is_anonymous: boolean;
is_multiple_choice: boolean;
is_quiz: boolean;
is_closed: boolean;
closes_at?: string | null;
allows_custom_answers: boolean;
total_voters: number;
user_voted_option_ids: number[];
options: PollOption[];
}
export interface CreatePollPayload {
chat_id: number;
question: string;
options_data: string[];
is_anonymous: boolean;
is_multiple_choice: boolean;
closes_in_minutes?: number | null;
reply_to_id?: number;
}
export interface CreatePollResponse {
poll: MessagePoll;
message: Message;
}
export interface PollVoteResponse {
ok: boolean;
results: MessagePoll;
}
export interface MessageAttachment {
id: number;
file_name: string;