feat(messages): add polls and realtime read state
This commit is contained in:
parent
5214d6fa45
commit
96d4a188b7
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
100
backend/communications/polls.py
Normal file
100
backend/communications/polls.py
Normal 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]
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
Обновление заявки на закупку.
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
231
frontend/src/components/messages/CreatePollModal.tsx
Normal file
231
frontend/src/components/messages/CreatePollModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
207
frontend/src/components/messages/MessagePollCard.tsx
Normal file
207
frontend/src/components/messages/MessagePollCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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' }),
|
||||
};
|
||||
}
|
||||
|
||||
39
frontend/src/lib/message-read-state.test.js
Normal file
39
frontend/src/lib/message-read-state.test.js
Normal 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);
|
||||
});
|
||||
7
frontend/src/lib/messages/messageReadState.d.ts
vendored
Normal file
7
frontend/src/lib/messages/messageReadState.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
import type { Message, MessageReader } from "@/types/api";
|
||||
|
||||
export function applyMessageReadReceipt(
|
||||
messages: Message[],
|
||||
lastReadMessageId: number,
|
||||
reader: MessageReader,
|
||||
): Message[];
|
||||
34
frontend/src/lib/messages/messageReadState.js
Normal file
34
frontend/src/lib/messages/messageReadState.js
Normal 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;
|
||||
}
|
||||
10
frontend/src/lib/messages/pollState.d.ts
vendored
Normal file
10
frontend/src/lib/messages/pollState.d.ts
vendored
Normal 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[];
|
||||
29
frontend/src/lib/messages/pollState.js
Normal file
29
frontend/src/lib/messages/pollState.js
Normal 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;
|
||||
}
|
||||
37
frontend/src/lib/poll-state.test.js
Normal file
37
frontend/src/lib/poll-state.test.js
Normal 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]);
|
||||
});
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user