diff --git a/backend/api/apps.py b/backend/api/apps.py index b825a4b1..9684de73 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -47,13 +47,17 @@ def create_ai_account(sender, **kwargs): User.objects.create( userID="user_ai", username='ai', - displayName='Prune', + displayName='Prune 🤖', email='prune@brandoncodes.dev', password='', lang='EN', avatarID=f"data:image/jpeg;base64,{encoded_avatar}", flags=3 ) + else: + ai_account = User.objects.get(userID="user_ai") + ai_account.displayName = 'Prune 🤖' + ai_account.save() settings, _ = UserSettings.objects.get_or_create(userID="user_ai") store_items = StoreItem.objects.all() diff --git a/backend/api/consumers/match.py b/backend/api/consumers/match.py index 5827f35c..8b7b85c5 100644 --- a/backend/api/consumers/match.py +++ b/backend/api/consumers/match.py @@ -15,6 +15,7 @@ from django.db.models import Q from django.utils import timezone from django.conf import settings +from django.core.cache import cache from ..models import Conversation, User, Relationship, Match, UserSettings, Tournament from ..util import generate_id, get_safe_profile, get_user_id_from_token @@ -23,6 +24,353 @@ logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) +class StatusConsumer(AsyncWebsocketConsumer): + + async def connect(self): + self.user = None + self.heartbeat_task = None + self.failed_heartbeats = 0 + self.first_heartbeat = True + + query_string = self.scope['query_string'].decode() + query_params = urllib.parse.parse_qs(query_string) + token = query_params.get('token', [None])[0] + + if token is None: + logger.info(f"[{self.__class__.__name__}] Connection attempt without token") + await self.close() + return + + userID = await get_user_id_from_token(token) + + if userID is None: + logger.info(f"[{self.__class__.__name__}] Connection attempt with invalid token") + await self.close() + return + + self.user = await sync_to_async(User.objects.get)(userID=userID) + self.user_group_name = f"status_{self.user.userID}" + + connection_count_key = f"status_user_connections_{self.user.userID}" + connection_count = cache.get(connection_count_key, 0) + cache.set(connection_count_key, connection_count + 1, timeout=None) + + await self.channel_layer.group_add( + self.user_group_name, + self.channel_name + ) + + await self.accept() + self.heartbeat_task = asyncio.create_task(self.check_heartbeat()) + logger.info(f"[{self.__class__.__name__}] User {self.user.username} connected") + + async def disconnect(self, close_code): + + if self.heartbeat_task and not self.heartbeat_task.done(): + self.heartbeat_task.cancel() + if self.user is not None: + connection_count_key = f"status_user_connections_{self.user.userID}" + connection_count = cache.get(connection_count_key, 0) - 1 + + await self.channel_layer.group_discard( + self.user_group_name, + self.channel_name + ) + + if connection_count > 0: + cache.set(connection_count_key, connection_count, timeout=None) + logger.info(f"[{self.__class__.__name__}] User {self.user.username} disconnected, {connection_count} connections remaining") + else: + cache.delete(connection_count_key) + await self.update_user_status(False, None) + await self.notify_friends_connection(self.user) + logger.info(f"[{self.__class__.__name__}] User {self.user.username} disconnected") + + async def receive(self, text_data): + try: + json_data = json.loads(text_data) + message_type = json_data.get("type", None) + + if not message_type: + raise Exception("Missing message type") + + match message_type: + case "heartbeat": + activity = json_data.get("activity", None) + + if activity is None: + raise Exception("Missing activity") + if activity not in ["HOME", "QUEUEING", "PLAYING_VS_AI", "PLAYING_MULTIPLAYER", "PLAYING_LOCAL"]: + raise Exception("Invalid activity, not in [HOME, QUEUEING, PLAYING_VS_AI, PLAYING_MULTIPLAYER, PLAYING_LOCAL]") + + self.failed_heartbeats = 0 + await self.update_user_status(True, activity) + + if self.first_heartbeat: + await self.notify_friends_connection(self.user) + self.first_heartbeat = False + + case _: + raise Exception(f"Invalid message type: {message_type}") + + except Exception as err: + try: + await self.send(json.dumps({ + "type": "error", + "message": "Invalid JSON", + "more_info": str(err) + })) + except Exception as _: + pass + + @sync_to_async + def update_user_status(self, online, activity): + try: + self.user = User.objects.get(userID=self.user.userID) + except User.DoesNotExist: + return + + self.user.status = { + "online": online, + "activity": activity, + "last_seen": timezone.now().isoformat() + } + self.user.save() + + async def notify_friends_connection(self, user): + friends = await sync_to_async(list)( + Relationship.objects.filter( + Q(userA=user.userID) | Q(userB=user.userID), + status=1 + ) + ) + + for relationship in friends: + friend_id = relationship.userA if relationship.userA != user.userID else relationship.userB + + try: + friend = await sync_to_async(User.objects.get)(userID=friend_id) + except User.DoesNotExist: + continue + + await self.channel_layer.group_send( + f"status_{friend.userID}", + { + "type": "connection_event", + "user": get_safe_profile(UserSerializer(user).data, me=False) + } + ) + + async def connection_event(self, event): + try: + await self.send(json.dumps({ + "type": "connection_event", + "user": event["user"] + })) + except Exception as _: + pass + + async def check_heartbeat(self): + while True: + self.failed_heartbeats += 1 + + if self.failed_heartbeats >= 3: + logger.info(f"[{self.__class__.__name__}] User {self.user.username} missed 3 heartbeats, closing connection") + await self.close(code=4000) + break + + try: + await self.send(json.dumps({ + "type": "heartbeat" + })) + except Exception as _: + pass + await asyncio.sleep(2) + +class ChatConsumer(AsyncWebsocketConsumer): + + async def connect(self): + self.user = None + + query_string = self.scope['query_string'].decode() + query_params = urllib.parse.parse_qs(query_string) + token = query_params.get('token', [None])[0] + + if token is None: + logger.info(f"[{self.__class__.__name__}] Connection attempt without token") + await self.close() + return + + userID = await get_user_id_from_token(token) + + if userID is None: + logger.info(f"[{self.__class__.__name__}] Connection attempt with invalid token") + await self.close() + return + + self.user = await sync_to_async(User.objects.get)(userID=userID) + self.user_group_name = f"chat_{self.user.userID}" + + connection_count_key = f"chat_user_connections_{self.user.userID}" + connection_count = cache.get(connection_count_key, 0) + cache.set(connection_count_key, connection_count + 1, timeout=None) + + await self.channel_layer.group_add( + self.user_group_name, + self.channel_name + ) + + if connection_count <= 0: + await self.ensure_conversations_exist(self.user) + logger.info(f"[{self.__class__.__name__}] User {self.user.username} connected, conversations ensured") + else: + logger.info(f"[{self.__class__.__name__}] User {self.user.username} reconnected") + await self.accept() + + async def disconnect(self, close_code): + if self.user: + connection_count_key = f"chat_user_connections_{self.user.userID}" + connection_count = cache.get(connection_count_key, 0) - 1 + + await self.channel_layer.group_discard( + self.user_group_name, + self.channel_name + ) + + if connection_count > 0: + cache.set(connection_count_key, connection_count, timeout=None) + logger.info(f"[{self.__class__.__name__}] User {self.user.username} disconnected, {connection_count} connections remaining") + else: + cache.delete(connection_count_key) + logger.info(f"[{self.__class__.__name__}] User {self.user.username} disconnected") + + async def receive(self, text_data): + try: + json_data = json.loads(text_data) + message_type = json_data.get("type", None) + + if not message_type: + raise Exception("Missing message type") + + match message_type: + case "send_message": + conversation_id = json_data.get("conversationID", None) + content = json_data.get("content", None) + + if self.user is None: + raise Exception("Something went wrong when receiving the message") + if conversation_id is None: + raise Exception("Missing conversation ID") + if content is None: + raise Exception("Missing content") + + logger.info(f"[{self.__class__.__name__}] Received message from {self.user.username}: {json_data}") + message = await self.add_message_to_conversation(conversation_id, self.user, content) + await self.notify_new_message(conversation_id, self.user, message) + + case _: + raise Exception(f"Invalid message type: {message_type}") + except Exception as err: + try: + await self.send(json.dumps({ + "type": "error", + "message": "Invalid JSON", + "more_info": str(err) + })) + except Exception as _: + pass + + async def notify_new_message(self, conversation_id, sender, message): + conversation = await sync_to_async(Conversation.objects.get)(conversationID=conversation_id) + + if not conversation: + raise Exception(f"Conversation {conversation_id} not found") + + participants = await sync_to_async(list)(conversation.participants.all()) + safe_profile = get_safe_profile(UserSerializer(sender).data, me=False) + + for participant in participants: + participant_group_name = f"chat_{participant.userID}" + + await self.channel_layer.group_send( + participant_group_name, + { + "type": "conversation_update", + "conversationID": conversation_id, + "sender": safe_profile, + "message": MessageSerializer(message).data + } + ) + + async def conversation_update(self, event): + try: + await self.send(json.dumps({ + "type": "conversation_update", + "conversationID": event["conversationID"], + "sender": event["sender"], + "message": event["message"] + })) + except Exception as _: + pass + + async def friend_request(self, event): + try: + if event["status"] == "accepted": + await self.ensure_conversations_exist(self.user) + + await self.send(json.dumps({ + "type": "friend_request", + "status": event["status"], + "data": event["data"] + })) + except Exception as _: + pass + + @sync_to_async + def add_message_to_conversation(self, conversation_id, user, content): + conversation = Conversation.objects.get(conversationID=conversation_id) + + if not conversation: + raise Exception(f"Conversation {conversation_id} not found") + + if user not in conversation.participants.all(): + raise Exception(f"User {user.username} is not part of conversation {conversation_id}") + + message = conversation.messages.create(messageID=generate_id("msg"), sender=user, content=content) + conversation.save() + return message + + @sync_to_async + def ensure_conversations_exist(self, user): + friends = Relationship.objects.filter( + Q(userA=user.userID) | Q(userB=user.userID) + ) + friends = friends.exclude( + Q(status=2) | Q(status=0), + Q(userA=user.userID) | Q(userB=user.userID) + ) + + user = User.objects.get(userID=user.userID) + + for relationship in friends: + friend_id = relationship.userA if relationship.userA != user.userID else relationship.userB + + try: + friend = User.objects.get(userID=friend_id) + except User.DoesNotExist: + continue + + existing_conversation = Conversation.objects.filter( + participants__userID__in=[user.userID, friend_id], + conversationType='private_message' + ).annotate(participant_count=Count('participants')).filter(participant_count=2).exists() + + if not existing_conversation: + new_conversation = Conversation.objects.create(conversationID=generate_id("conv"), conversationType='private_message') + new_conversation.receipientID = user.userID + new_conversation.participants.add(user, friend) + new_conversation.save() + class MatchConsumer(AsyncJsonWebsocketConsumer): active_matches = {} diff --git a/frontend/src/components/Home/styles/Contributors.styled.js b/frontend/src/components/Home/styles/Contributors.styled.js index 239eea1e..5dd5f1ea 100644 --- a/frontend/src/components/Home/styles/Contributors.styled.js +++ b/frontend/src/components/Home/styles/Contributors.styled.js @@ -44,12 +44,14 @@ export const CardsContainer = styled.div` align-items: center; width: 90%; max-width: 1200px; + flex-wrap: wrap; `; export const Cards = styled.div` display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 2rem; + width: 100%; `; export const Card = styled.div` diff --git a/frontend/src/components/Leaderboard/tools/PodiumPlayer.js b/frontend/src/components/Leaderboard/tools/PodiumPlayer.js index 7af96dd7..8a6df506 100644 --- a/frontend/src/components/Leaderboard/tools/PodiumPlayer.js +++ b/frontend/src/components/Leaderboard/tools/PodiumPlayer.js @@ -10,11 +10,12 @@ import { PodiumBase, Badge, } from '../styles/Podium.styled'; +import { formatUserData } from '../../../api/user'; const PodiumPlayer = ({ player, position, selectedStat }) => { const navigate = useNavigate(); const positionClass = position === 0 ? 'second' : position === 1 ? 'first' : 'third'; - const playerName = player?.user.username || 'N/A'; + const formattedPlayer = player ? formatUserData(player.user) : { displayName: 'N/A' }; const playerScore = player?.stats[selectedStat] || 0; const handleClickUsername = (username) => { @@ -23,13 +24,15 @@ const PodiumPlayer = ({ player, position, selectedStat }) => { return ( - + - handleClickUsername(playerName)} - style={{ paddingTop: '10px', fontWeight: 'bold' }} - > - {playerName} +
+ {formattedPlayer.displayName !== 'N/A' ? ( + handleClickUsername(formattedPlayer.username)}>{formattedPlayer.displayName} + ) : ( + N/A + )} +