Restore soft-deleted message reactions and enforce uniqueness only for active reactions; add WebSocket error handling and minor UI/Docker tweaks. - backend/social/chat/models.py: Toggle reaction now restores a stale soft-deleted MessageReaction (avoids unique conflicts) and creates new reactions as needed. Replaced unique_together with a conditional UniqueConstraint that applies only to non-deleted records. - backend/social/chat/consumers.py: Wrap reaction toggle in try/except to return a WS error message on failure instead of allowing exceptions to bubble up. - frontend/src/components/social/chat/Message.tsx: Adjusted Tailwind max-width class for the reaction menu (max-w-32). - docker-compose.yml: Added commented example configuration for an optional Janus media server (documentational/commented service). These changes prevent unique constraint errors when restoring reactions, improve robustness of the WebSocket reaction flow, and include small UI and deployment notes.
225 lines
7.3 KiB
Python
225 lines
7.3 KiB
Python
# chat/consumers.py
|
|
import json
|
|
|
|
from channels.db import database_sync_to_async
|
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
|
from django.utils import timezone
|
|
|
|
from .models import Chat, ChatReadStatus, Message
|
|
|
|
|
|
class ChatConsumer(AsyncWebsocketConsumer):
|
|
|
|
# -- CONNECT --
|
|
|
|
async def connect(self):
|
|
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
|
|
self.chat_name = f"chat_{self.chat_id}"
|
|
user = self.scope["user"]
|
|
|
|
if not user.is_authenticated:
|
|
await self.close(code=4401)
|
|
return
|
|
|
|
is_member = await _is_chat_member(self.chat_id, user)
|
|
if not is_member:
|
|
await self.close(code=4403)
|
|
return
|
|
|
|
await self.channel_layer.group_add(self.chat_name, self.channel_name)
|
|
await self.accept()
|
|
|
|
# -- DISCONNECT --
|
|
|
|
async def disconnect(self, close_code):
|
|
await self.channel_layer.group_discard(self.chat_name, self.channel_name)
|
|
|
|
# -- RECEIVE --
|
|
|
|
async def receive(self, text_data):
|
|
data = json.loads(text_data)
|
|
user = self.scope["user"]
|
|
msg_type = data.get("type")
|
|
|
|
if msg_type == "new_chat_message":
|
|
message = await _create_message(
|
|
chat_id=self.chat_id,
|
|
sender=user,
|
|
content=data["message"],
|
|
)
|
|
await self.channel_layer.group_send(self.chat_name, {
|
|
"type": "chat.message",
|
|
"message_id": message.id,
|
|
"message": message.content,
|
|
"sender_id": user.id,
|
|
"sender": user.username,
|
|
"sender_avatar": user.avatar.url if user.avatar else None,
|
|
})
|
|
|
|
elif msg_type == "new_reply_chat_message":
|
|
message = await _create_message(
|
|
chat_id=self.chat_id,
|
|
sender=user,
|
|
content=data["message"],
|
|
reply_to_id=data.get("reply_to_id"),
|
|
)
|
|
await self.channel_layer.group_send(self.chat_name, {
|
|
"type": "reply.chat.message",
|
|
"message_id": message.id,
|
|
"message": message.content,
|
|
"reply_to_id": data.get("reply_to_id"),
|
|
"sender_id": user.id,
|
|
"sender": user.username,
|
|
"sender_avatar": user.avatar.url if user.avatar else None,
|
|
})
|
|
|
|
elif msg_type == "reaction":
|
|
try:
|
|
action, _reaction = await _toggle_reaction(
|
|
message_id=data["message_id"],
|
|
user=user,
|
|
emoji=data["emoji"],
|
|
)
|
|
except Exception:
|
|
await self.send(text_data=json.dumps({"error": "Reaction failed."}))
|
|
return
|
|
|
|
await self.channel_layer.group_send(self.chat_name, {
|
|
"type": "message.reaction",
|
|
"message_id": data["message_id"],
|
|
"emoji": data["emoji"],
|
|
"user": user.username,
|
|
"user_id": user.id,
|
|
"action": action,
|
|
})
|
|
|
|
elif msg_type == "typing":
|
|
await self.channel_layer.group_send(self.chat_name, {
|
|
"type": "typing.status",
|
|
"user": user.username,
|
|
"is_typing": data.get("is_typing", True),
|
|
})
|
|
|
|
elif msg_type == "stop_typing":
|
|
await self.channel_layer.group_send(self.chat_name, {
|
|
"type": "stop.typing",
|
|
"user": user.username,
|
|
})
|
|
|
|
elif msg_type == "mark_read":
|
|
await _mark_read(chat_id=self.chat_id, user=user)
|
|
await self.channel_layer.group_send(self.chat_name, {
|
|
"type": "read.status",
|
|
"user": user.username,
|
|
"chat_id": int(self.chat_id),
|
|
})
|
|
|
|
else:
|
|
await self.send(text_data=json.dumps({"error": "Unsupported message type."}))
|
|
|
|
# -- GROUP EVENT HANDLERS --
|
|
# These are called by the channel layer when another part of the system
|
|
# (consumer or view) calls group_send.
|
|
|
|
async def chat_message(self, event):
|
|
await self.send(text_data=json.dumps({
|
|
"type": "new_chat_message",
|
|
"message_id": event["message_id"],
|
|
"message": event["message"],
|
|
"sender_id": event.get("sender_id"),
|
|
"sender": event["sender"],
|
|
"sender_avatar": event.get("sender_avatar"),
|
|
"reply_to_id": event.get("reply_to_id"),
|
|
"media_files": event.get("media_files", []),
|
|
}))
|
|
|
|
async def reply_chat_message(self, event):
|
|
await self.send(text_data=json.dumps({
|
|
"type": "new_reply_chat_message",
|
|
"message_id": event["message_id"],
|
|
"message": event["message"],
|
|
"reply_to_id": event["reply_to_id"],
|
|
"sender_id": event["sender_id"],
|
|
"sender": event["sender"],
|
|
"sender_avatar": event["sender_avatar"],
|
|
}))
|
|
|
|
async def edit_message(self, event):
|
|
await self.send(text_data=json.dumps({
|
|
"type": "edit_chat_message",
|
|
"message_id": event["message_id"],
|
|
"content": event["content"],
|
|
"is_edited": event.get("is_edited", True),
|
|
}))
|
|
|
|
async def delete_message(self, event):
|
|
await self.send(text_data=json.dumps({
|
|
"type": "delete_chat_message",
|
|
"message_id": event["message_id"],
|
|
}))
|
|
|
|
async def message_reaction(self, event):
|
|
await self.send(text_data=json.dumps({
|
|
"type": "reaction",
|
|
"message_id": event["message_id"],
|
|
"emoji": event["emoji"],
|
|
"user": event["user"],
|
|
"user_id": event["user_id"],
|
|
"action": event["action"],
|
|
}))
|
|
|
|
async def typing_status(self, event):
|
|
await self.send(text_data=json.dumps({
|
|
"type": "typing",
|
|
"user": event["user"],
|
|
"is_typing": event["is_typing"],
|
|
}))
|
|
|
|
async def stop_typing(self, event):
|
|
await self.send(text_data=json.dumps({
|
|
"type": "stop_typing",
|
|
"user": event["user"],
|
|
}))
|
|
|
|
async def read_status(self, event):
|
|
await self.send(text_data=json.dumps({
|
|
"type": "read_status",
|
|
"user": event["user"],
|
|
"chat_id": event["chat_id"],
|
|
}))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DB helpers (run in thread pool via database_sync_to_async)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@database_sync_to_async
|
|
def _is_chat_member(chat_id, user):
|
|
from django.db.models import Q
|
|
return Chat.objects.filter(Q(pk=chat_id), Q(members=user) | Q(owner=user)).exists()
|
|
|
|
|
|
@database_sync_to_async
|
|
def _create_message(chat_id, sender, content, reply_to_id=None):
|
|
return Message.objects.create(
|
|
chat_id=chat_id,
|
|
sender=sender,
|
|
content=content,
|
|
reply_to_id=reply_to_id,
|
|
)
|
|
|
|
|
|
@database_sync_to_async
|
|
def _toggle_reaction(message_id, user, emoji):
|
|
message = Message.objects.get(pk=message_id)
|
|
return message.toggle_reaction(user, emoji)
|
|
|
|
|
|
@database_sync_to_async
|
|
def _mark_read(chat_id, user):
|
|
ChatReadStatus.objects.update_or_create(
|
|
user=user,
|
|
chat_id=chat_id,
|
|
defaults={"last_read_at": timezone.now()},
|
|
)
|