diff --git a/chat/admin.py b/chat/admin.py index 757dcac..524a1d6 100644 --- a/chat/admin.py +++ b/chat/admin.py @@ -8,6 +8,9 @@ from .models import Channel, Message @admin.register(Channel) class ChannelAdmin(admin.ModelAdmin): + """ + Modèle d'administration des canaux de chat. + """ list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',) list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',) search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',) @@ -16,6 +19,9 @@ class ChannelAdmin(admin.ModelAdmin): @admin.register(Message) class MessageAdmin(admin.ModelAdmin): + """ + Modèle d'administration des messages de chat. + """ list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',) list_filter = ('channel', 'created_at', 'updated_at',) search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',) diff --git a/chat/consumers.py b/chat/consumers.py index 168284e..41e0f1b 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -11,76 +11,101 @@ from .models import Channel, Message class ChatConsumer(AsyncJsonWebsocketConsumer): """ - This consumer manages the websocket of the chat interface. + Ce consommateur gère les connexions WebSocket pour le chat. """ async def connect(self) -> None: """ - This function is called when a new websocket is trying to connect to the server. - We accept only if this is a user of a team of the associated tournament, or a volunteer - of the tournament. + Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur. + On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e. """ if '_fake_user_id' in self.scope['session']: + # Dans le cas d'une impersonification, on charge l'utilisateur⋅rice concerné self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id']) - # Fetch the registration of the current user + # Récupération de l'utilisateur⋅rice courant⋅e user = self.scope['user'] if user.is_anonymous: - # User is not authenticated + # L'utilisateur⋅rice n'est pas connecté⋅e await self.close() return reg = await Registration.objects.aget(user_id=user.id) self.registration = reg - # Accept the connection + # Acceptation de la connexion await self.accept() + # Récupération des canaux accessibles en lecture et/ou en écriture self.read_channels = await Channel.get_accessible_channels(user, 'read') self.write_channels = await Channel.get_accessible_channels(user, 'write') + # Abonnement aux canaux de diffusion Websocket pour les différents canaux de chat async for channel in self.read_channels.all(): await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) + # Abonnement à un canal de diffusion Websocket personnel, utile pour s'adresser à une unique personne await self.channel_layer.group_add(f"user-{user.id}", self.channel_name) - async def disconnect(self, close_code) -> None: + async def disconnect(self, close_code: int) -> None: """ - Called when the websocket got disconnected, for any reason. - :param close_code: The error code. + Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur. + :param close_code: Le code d'erreur. """ if self.scope['user'].is_anonymous: - # User is not authenticated + # L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien return async for channel in self.read_channels.all(): + # Désabonnement des canaux de diffusion Websocket liés aux canaux de chat await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name) + # Désabonnement du canal de diffusion Websocket personnel await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name) - async def receive_json(self, content, **kwargs): + async def receive_json(self, content: dict, **kwargs) -> None: """ - Called when the client sends us some data, parsed as JSON. - :param content: The sent data, decoded from JSON text. Must content a `type` field. + Appelée lorsque le client nous envoie des données, décodées depuis du JSON. + :param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'. """ match content['type']: case 'fetch_channels': + # Demande de récupération des canaux disponibles await self.fetch_channels() case 'send_message': + # Envoi d'un message dans un canal await self.receive_message(**content) case 'edit_message': + # Modification d'un message await self.edit_message(**content) case 'delete_message': + # Suppression d'un message await self.delete_message(**content) case 'fetch_messages': + # Récupération des messages d'un canal (ou d'une partie) await self.fetch_messages(**content) case 'mark_read': + # Marquage de messages comme lus await self.mark_read(**content) case 'start_private_chat': + # Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice await self.start_private_chat(**content) case unknown: - print("Unknown message type:", unknown) + # Type inconnu, on soulève une erreur + raise ValueError(f"Unknown message type: {unknown}") async def fetch_channels(self) -> None: + """ + L'utilisateur⋅rice demande à récupérer la liste des canaux disponibles. + On lui renvoie alors la liste des canaux qui lui sont accessibles en lecture, + en fournissant nom, catégorie, permission de lecture et nombre de messages non lus. + """ user = self.scope['user'] + # Récupération des canaux accessibles en lecture, avec le nombre de messages non lus + channels = self.read_channels.prefetch_related('invited') \ + .annotate(total_messages=Count('messages', distinct=True)) \ + .annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) \ + .annotate(unread_messages=F('total_messages') - F('read_messages')).all() + + # Envoi de la liste des canaux message = { 'type': 'fetch_channels', 'channels': [ @@ -92,27 +117,37 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): 'write_access': await self.write_channels.acontains(channel), 'unread_messages': channel.unread_messages, } - async for channel in self.read_channels.prefetch_related('invited') - .annotate(total_messages=Count('messages', distinct=True)) - .annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) - .annotate(unread_messages=F('total_messages') - F('read_messages')).all() + async for channel in channels ] } await self.send_json(message) async def receive_message(self, channel_id: int, content: str, **kwargs) -> None: + """ + L'utilisateur⋅ice a envoyé un message dans un canal. + On vérifie d'abord la permission d'écriture, puis on crée le message et on l'envoie à tou⋅tes les + utilisateur⋅ices abonné⋅es au canal. + + :param channel_id: Identifiant du canal où envoyer le message. + :param content: Contenu du message. + """ user = self.scope['user'] + + # Récupération du canal channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \ .aget(id=channel_id) if not await self.write_channels.acontains(channel): + # L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne return + # Création du message message = await Message.objects.acreate( author=user, channel=channel, content=content, ) + # Envoi du message à toutes les personnes connectées sur le canal await self.channel_layer.group_send(f'chat-{channel.id}', { 'type': 'chat.send_message', 'id': message.id, @@ -124,14 +159,27 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): }) async def edit_message(self, message_id: int, content: str, **kwargs) -> None: - message = await Message.objects.aget(id=message_id) + """ + L'utilisateur⋅ice a modifié un message. + On vérifie d'abord que l'utilisateur⋅ice a le droit de modifier le message, puis on modifie le message + et on envoie la modification à tou⋅tes les utilisateur⋅ices abonné⋅es au canal. + + :param message_id: Identifiant du message à modifier. + :param content: Nouveau contenu du message. + """ user = self.scope['user'] + + # Récupération du message + message = await Message.objects.aget(id=message_id) if user.id != message.author_id and not user.is_superuser: + # Seul⋅e l'auteur⋅ice du message ou un⋅e admin peut modifier un message return + # Modification du contenu du message message.content = content await message.asave() + # Envoi de la modification à tou⋅tes les personnes connectées sur le canal await self.channel_layer.group_send(f'chat-{message.channel_id}', { 'type': 'chat.edit_message', 'id': message_id, @@ -140,13 +188,24 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): }) async def delete_message(self, message_id: int, **kwargs) -> None: - message = await Message.objects.aget(id=message_id) + """ + L'utilisateur⋅ice a supprimé un message. + On vérifie d'abord que l'utilisateur⋅ice a le droit de supprimer le message, puis on supprime le message + et on envoie la suppression à tou⋅tes les utilisateur⋅ices abonné⋅es au canal. + + :param message_id: Identifiant du message à supprimer. + """ user = self.scope['user'] + + # Récupération du message + message = await Message.objects.aget(id=message_id) if user.id != message.author_id and not user.is_superuser: return + # Suppression effective du message await message.adelete() + # Envoi de la suppression à tou⋅tes les personnes connectées sur le canal await self.channel_layer.group_send(f'chat-{message.channel_id}', { 'type': 'chat.delete_message', 'id': message_id, @@ -154,16 +213,30 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): }) async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None: + """ + L'utilisateur⋅ice demande à récupérer les messages d'un canal. + On vérifie la permission de lecture, puis on renvoie les messages demandés. + + :param channel_id: Identifiant du canal où récupérer les messages. + :param offset: Décalage pour la pagination, à partir du dernier message. + Par défaut : 0, on commence au dernier message. + :param limit: Nombre de messages à récupérer. Par défaut, on récupère 50 messages. + """ + # Récupération du canal channel = await Channel.objects.aget(id=channel_id) if not await self.read_channels.acontains(channel): + # L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne return - limit = min(limit, 200) # Fetch only maximum 200 messages at the time + limit = min(limit, 200) # On limite le nombre de messages à 200 maximum + # Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e messages = Message.objects \ .filter(channel=channel) \ .annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \ .order_by('-created_at')[offset:offset + limit].all() + + # Envoi de la liste des messages, en les renvoyant dans l'ordre chronologique await self.send_json({ 'type': 'fetch_messages', 'channel_id': channel_id, @@ -181,13 +254,22 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): }) async def mark_read(self, message_ids: list[int], **_kwargs) -> None: + """ + L'utilisateur⋅ice marque des messages comme lus, après les avoir affichés à l'écran. + + :param message_ids: Liste des identifiants des messages qu'il faut marquer comme lus. + """ + # Récupération des messages à marquer comme lus messages = Message.objects.filter(id__in=message_ids) async for message in messages.all(): + # Ajout de l'utilisateur⋅ice courant⋅e à la liste des personnes ayant lu le message await message.users_read.aadd(self.scope['user']) + # Actualisation du nombre de messages non lus par canal unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \ .annotate(unread_messages=Count('channel_id')) + # Envoi des identifiants des messages non lus et du nombre de messages non lus par canal, actualisés await self.send_json({ 'type': 'mark_read', 'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()], @@ -196,10 +278,21 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): }) async def start_private_chat(self, user_id: int, **kwargs) -> None: + """ + L'utilisateur⋅ice souhaite démarrer une conversation privée avec un⋅e autre utilisateur⋅ice. + Pour cela, on récupère le salon privé s'il existe, sinon on en crée un. + Dans le cas d'une création, les deux personnes sont transférées immédiatement dans ce nouveau canal. + + :param user_id: L'utilisateur⋅rice avec qui démarrer la conversation privée. + """ user = self.scope['user'] + # Récupération de l'autre utilisateur⋅ice avec qui démarrer la conversation other_user = await User.objects.aget(id=user_id) + + # Vérification de l'existence d'un salon privé entre les deux personnes channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user) if not await channel_qs.aexists(): + # Le salon privé n'existe pas, on le crée alors channel = await Channel.objects.acreate( name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}", category=Channel.ChannelCategory.PRIVATE, @@ -207,9 +300,11 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): ) await channel.invited.aset([user, other_user]) + # On s'ajoute au salon privé await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) if user != other_user: + # On transfère l'autre utilisateur⋅ice dans le salon privé await self.channel_layer.group_send(f"user-{other_user.id}", { 'type': 'chat.start_private_chat', 'channel': { @@ -221,8 +316,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): } }) else: + # Récupération dudit salon privé channel = await channel_qs.afirst() + # Invitation de l'autre utilisateur⋅rice à rejoindre le salon privé await self.channel_layer.group_send(f"user-{user.id}", { 'type': 'chat.start_private_chat', 'channel': { @@ -235,17 +332,39 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): }) async def chat_send_message(self, message) -> None: + """ + Envoi d'un message à tou⋅tes les personnes connectées sur un canal. + :param message: Dictionnaire contenant les informations du message à envoyer, + contenant l'identifiant du message "id", l'identifiant du canal "channel_id", + l'heure de création "timestamp", l'identifiant de l'auteur "author_id", + le nom de l'auteur "author" et le contenu du message "content". + """ await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'], 'timestamp': message['timestamp'], 'author': message['author'], 'content': message['content']}) async def chat_edit_message(self, message) -> None: + """ + Envoi d'une modification de message à tou⋅tes les personnes connectées sur un canal. + :param message: Dictionnaire contenant les informations du message à modifier, + contenant l'identifiant du message "id", l'identifiant du canal "channel_id" + et le nouveau contenu "content". + """ await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'], 'content': message['content']}) async def chat_delete_message(self, message) -> None: + """ + Envoi d'une suppression de message à tou⋅tes les personnes connectées sur un canal. + :param message: Dictionnaire contenant les informations du message à supprimer, + contenant l'identifiant du message "id" et l'identifiant du canal "channel_id". + """ await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']}) async def chat_start_private_chat(self, message) -> None: + """ + Envoi d'un message pour démarrer une conversation privée à une personne connectée. + :param message: Dictionnaire contenant les informations du nouveau canal privé. + """ await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name) await self.send_json({'type': 'start_private_chat', 'channel': message['channel']}) diff --git a/chat/management/commands/create_chat_channels.py b/chat/management/commands/create_chat_channels.py index 666ee3f..ea2a343 100644 --- a/chat/management/commands/create_chat_channels.py +++ b/chat/management/commands/create_chat_channels.py @@ -10,9 +10,18 @@ from ...models import Channel class Command(BaseCommand): + """ + Cette commande permet de créer les canaux de chat pour les tournois et les équipes. + Différents canaux sont créés pour chaque tournoi, puis pour chaque poule. + Enfin, un canal de communication par équipe est créé. + """ + help = "Create chat channels for tournaments and teams." + def handle(self, *args, **kwargs): activate('fr') + # Création de canaux généraux, d'annonces, d'aide jurys et orgas, etc. + # Le canal d'annonces est accessibles à tous⋅tes, mais seul⋅es les admins peuvent y écrire. Channel.objects.update_or_create( name="Annonces", defaults=dict( @@ -22,6 +31,7 @@ class Command(BaseCommand): ), ) + # Un canal d'aide pour les bénévoles est dédié. Channel.objects.update_or_create( name="Aide jurys et orgas", defaults=dict( @@ -31,6 +41,7 @@ class Command(BaseCommand): ), ) + # Un canal de discussion générale en lien avec le tournoi est accessible librement. Channel.objects.update_or_create( name="Général", defaults=dict( @@ -40,6 +51,8 @@ class Command(BaseCommand): ), ) + # Un canal de discussion entre participant⋅es est accessible à tous⋅tes, + # dont l'objectif est de faciliter la mise en relation entre élèves afin de constituer une équipe. Channel.objects.update_or_create( name="Je cherche une équipe", defaults=dict( @@ -49,6 +62,7 @@ class Command(BaseCommand): ), ) + # Un canal de discussion libre est accessible pour tous⋅tes. Channel.objects.update_or_create( name="Détente", defaults=dict( @@ -59,6 +73,10 @@ class Command(BaseCommand): ) for tournament in Tournament.objects.all(): + # Pour chaque tournoi, on crée un canal d'annonces, un canal général et un de détente, + # qui sont comme les canaux généraux du même nom mais réservés aux membres du tournoi concerné. + # Les membres d'un tournoi sont les organisateur⋅rices, les juré⋅es d'une poule du tournoi + # ainsi que les membres d'une équipe inscrite au tournoi et qui est validée. Channel.objects.update_or_create( name=f"{tournament.name} - Annonces", defaults=dict( @@ -89,6 +107,7 @@ class Command(BaseCommand): ), ) + # Un canal réservé à tous⋅tes les juré⋅es du tournoi est créé. Channel.objects.update_or_create( name=f"{tournament.name} - Juré⋅es", defaults=dict( @@ -100,6 +119,7 @@ class Command(BaseCommand): ) if tournament.remote: + # Dans le cadre d'un tournoi distanciel, un canal pour les président⋅es de jury est créé. Channel.objects.update_or_create( name=f"{tournament.name} - Président⋅es de jury", defaults=dict( @@ -111,6 +131,8 @@ class Command(BaseCommand): ) for pool in tournament.pools.all(): + # Pour chaque poule d'un tournoi distanciel, on crée un canal pour les membres de la poule + # (équipes et juré⋅es), et un pour les juré⋅es uniquement. Channel.objects.update_or_create( name=f"{tournament.name} - Poule {pool.short_name}", defaults=dict( @@ -132,6 +154,7 @@ class Command(BaseCommand): ) for team in Team.objects.filter(participation__valid=True).all(): + # Chaque équipe validée a le droit à son canal de communication. Channel.objects.update_or_create( name=f"Équipe {team.trigram}", defaults=dict( diff --git a/chat/migrations/0004_alter_channel_category_alter_channel_name_and_more.py b/chat/migrations/0004_alter_channel_category_alter_channel_name_and_more.py new file mode 100644 index 0000000..d0ef07a --- /dev/null +++ b/chat/migrations/0004_alter_channel_category_alter_channel_name_and_more.py @@ -0,0 +1,94 @@ +# Generated by Django 5.0.6 on 2024-05-26 20:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chat", "0003_message_users_read"), + ] + + operations = [ + migrations.AlterField( + model_name="channel", + name="category", + field=models.CharField( + choices=[ + ("general", "General channels"), + ("tournament", "Tournament channels"), + ("team", "Team channels"), + ("private", "Private channels"), + ], + default="general", + help_text="Category of the channel, between general channels, tournament-specific channels, team channels or private channels. Will be used to sort channels in the channel list.", + max_length=255, + verbose_name="category", + ), + ), + migrations.AlterField( + model_name="channel", + name="name", + field=models.CharField( + help_text="Visible name of the channel.", + max_length=255, + verbose_name="name", + ), + ), + migrations.AlterField( + model_name="channel", + name="read_access", + field=models.CharField( + choices=[ + ("anonymous", "Everyone, including anonymous users"), + ("authenticated", "Authenticated users"), + ("volunteer", "All volunteers"), + ("tournament", "All members of a given tournament"), + ("organizer", "Tournament organizers only"), + ( + "jury_president", + "Tournament organizers and jury presidents of the tournament", + ), + ("jury", "Jury members of the pool"), + ("pool", "Jury members and participants of the pool"), + ( + "team", + "Members of the team and organizers of concerned tournaments", + ), + ("private", "Private, reserved to explicit authorized users"), + ("admin", "Admin users"), + ], + help_text="Permission type that is required to read the messages of the channels.", + max_length=16, + verbose_name="read permission", + ), + ), + migrations.AlterField( + model_name="channel", + name="write_access", + field=models.CharField( + choices=[ + ("anonymous", "Everyone, including anonymous users"), + ("authenticated", "Authenticated users"), + ("volunteer", "All volunteers"), + ("tournament", "All members of a given tournament"), + ("organizer", "Tournament organizers only"), + ( + "jury_president", + "Tournament organizers and jury presidents of the tournament", + ), + ("jury", "Jury members of the pool"), + ("pool", "Jury members and participants of the pool"), + ( + "team", + "Members of the team and organizers of concerned tournaments", + ), + ("private", "Private, reserved to explicit authorized users"), + ("admin", "Admin users"), + ], + help_text="Permission type that is required to write a message to a channel.", + max_length=16, + verbose_name="write permission", + ), + ), + ] diff --git a/chat/models.py b/chat/models.py index 833890f..296f24f 100644 --- a/chat/models.py +++ b/chat/models.py @@ -13,6 +13,11 @@ from tfjm.permissions import PermissionType class Channel(models.Model): + """ + Ce modèle représente un canal de chat, défini par son nom, sa catégorie, les permissions de lecture et d'écriture + requises pour accéder au canal, et éventuellement un tournoi, une poule ou une équipe associée. + """ + class ChannelCategory(models.TextChoices): GENERAL = 'general', _("General channels") TOURNAMENT = 'tournament', _("Tournament channels") @@ -22,6 +27,7 @@ class Channel(models.Model): name = models.CharField( max_length=255, verbose_name=_("name"), + help_text=_("Visible name of the channel."), ) category = models.CharField( @@ -29,18 +35,22 @@ class Channel(models.Model): verbose_name=_("category"), choices=ChannelCategory, default=ChannelCategory.GENERAL, + help_text=_("Category of the channel, between general channels, tournament-specific channels, team channels " + "or private channels. Will be used to sort channels in the channel list."), ) read_access = models.CharField( max_length=16, verbose_name=_("read permission"), choices=PermissionType, + help_text=_("Permission type that is required to read the messages of the channels."), ) write_access = models.CharField( max_length=16, verbose_name=_("write permission"), choices=PermissionType, + help_text=_("Permission type that is required to write a message to a channel."), ) tournament = models.ForeignKey( @@ -92,10 +102,20 @@ class Channel(models.Model): ) def get_visible_name(self, user: User) -> str: + """ + Renvoie le nom du channel tel qu'il est visible pour l'utilisateur⋅rice donné. + Dans le cas d'un canal classique, renvoie directement le nom. + Dans le cas d'un canal privé, renvoie la liste des personnes membres du canal, + à l'exception de la personne connectée, afin de ne pas afficher son propre nom. + Dans le cas d'un chat avec uniquement soi-même, on affiche que notre propre nom. + """ if self.private: + # Le canal est privé, on renvoie la liste des personnes membres du canal + # à l'exception de soi-même (sauf si on est la seule personne dans le canal) users = [f"{u.first_name} {u.last_name}" for u in self.invited.all() if u != user] \ or [f"{user.first_name} {user.last_name}"] return ", ".join(users) + # Le canal est public, on renvoie directement le nom return self.name def __str__(self): @@ -103,39 +123,77 @@ class Channel(models.Model): @staticmethod async def get_accessible_channels(user: User, permission_type: str = 'read') -> QuerySet["Channel"]: + """ + Renvoie les canaux auxquels l'utilisateur⋅rice donné a accès, en lecture ou en écriture. + + Types de permissions : + ANONYMOUS : Tout le monde, y compris les utilisateur⋅rices non connecté⋅es + AUTHENTICATED : Toustes les utilisateur⋅rices connecté⋅es + VOLUNTEER : Toustes les bénévoles + TOURNAMENT_MEMBER : Toustes les membres d'un tournoi donné (orgas, juré⋅es, participant⋅es) + TOURNAMENT_ORGANIZER : Les organisateur⋅rices d'un tournoi donné + TOURNAMENT_JURY_PRESIDENT : Les organisateur⋅rices et les président⋅es de jury d'un tournoi donné + JURY_MEMBER : Les membres du jury d'une poule donnée, ou les organisateur⋅rices du tournoi + POOL_MEMBER : Les membres du jury et les participant⋅es d'une poule donnée, ou les organisateur⋅rices du tournoi + TEAM_MEMBER : Les membres d'une équipe donnée + PRIVATE : Les utilisateur⋅rices explicitement invité⋅es + ADMIN : Les utilisateur⋅rices administrateur⋅rices (qui ont accès à tout) + + Les canaux privés sont utilisés pour les messages privés, et ne sont pas affichés aux admins. + + :param user: L'utilisateur⋅rice dont on veut récupérer la liste des canaux. + :param permission_type: Le type de permission concerné (read ou write). + :return: Le Queryset des canaux autorisés. + """ permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access' qs = Channel.objects.none() if user.is_anonymous: + # Les utilisateur⋅rices non connecté⋅es ont accès aux canaux publics pour toustes return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS}) + # Les utilisateur⋅rices connecté⋅es ont accès aux canaux publics pour les personnes connectées qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED}) registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id) if registration.is_admin: + # Les administrateur⋅rices ont accès à tous les canaux, sauf les canaux privés sont iels ne sont pas membres return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all() if registration.is_volunteer: registration = await VolunteerRegistration.objects \ .prefetch_related('jury_in__tournament', 'organized_tournaments').aget(user_id=user.id) + # Les bénévoles ont accès aux canaux pour bénévoles qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER}) + # Iels ont accès aux tournois dont iels sont organisateur⋅rices ou juré⋅es + # pour la permission TOURNAMENT_MEMBER qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments), **{permission_type: PermissionType.TOURNAMENT_MEMBER}) + # Iels ont accès aux canaux pour les organisateur⋅rices des tournois dont iels sont organisateur⋅rices + # pour la permission TOURNAMENT_ORGANIZER qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()), **{permission_type: PermissionType.TOURNAMENT_ORGANIZER}) + # Iels ont accès aux canaux pour les organisateur⋅rices et président⋅es de jury des tournois dont iels sont + # organisateur⋅rices ou juré⋅es pour la permission TOURNAMENT_JURY_PRESIDENT qs |= Channel.objects.filter(Q(tournament__pools__in=registration.pools_presided.all()) | Q(tournament__in=registration.organized_tournaments.all()), **{permission_type: PermissionType.TOURNAMENT_JURY_PRESIDENT}) + # Iels ont accès aux canaux pour les juré⋅es des poules dont iels sont juré⋅es + # ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices + # pour la permission JURY_MEMBER qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all()) | Q(pool__tournament__in=registration.organized_tournaments.all()) | Q(pool__tournament__pools__in=registration.pools_presided.all()), **{permission_type: PermissionType.JURY_MEMBER}) + # Iels ont accès aux canaux pour les juré⋅es et participant⋅es des poules dont iels sont juré⋅es + # ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices + # pour la permission POOL_MEMBER qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all()) | Q(pool__tournament__in=registration.organized_tournaments.all()) | Q(pool__tournament__pools__in=registration.pools_presided.all()), @@ -151,15 +209,20 @@ class Channel(models.Model): if team.participation.final: tournaments.append(await Tournament.objects.aget(final=True)) + # Les participant⋅es ont accès aux canaux généraux pour le tournoi dont iels sont membres + # Cela comprend la finale s'iels sont finalistes qs |= Channel.objects.filter(Q(tournament__in=tournaments), **{permission_type: PermissionType.TOURNAMENT_MEMBER}) + # Iels ont accès aux canaux généraux pour les poules dont iels sont participant⋅es qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()), **{permission_type: PermissionType.POOL_MEMBER}) + # Iels ont accès aux canaux propres à leur équipe qs |= Channel.objects.filter(Q(team=team), **{permission_type: PermissionType.TEAM_MEMBER}) + # Les utilisateur⋅rices ont de plus accès aux messages privés qui leur sont adressés qs |= Channel.objects.filter(invited=user).prefetch_related('invited') return qs @@ -171,6 +234,12 @@ class Channel(models.Model): class Message(models.Model): + """ + Ce modèle représente un message de chat. + Un message appartient à un canal, et est défini par son contenu, son auteur⋅rice, sa date de création et sa date + de dernière modification. + De plus, on garde en mémoire les utilisateur⋅rices qui ont lu le message. + """ channel = models.ForeignKey( Channel, on_delete=models.CASCADE, @@ -208,55 +277,74 @@ class Message(models.Model): help_text=_("Users who have read the message."), ) - def get_author_name(self): + def get_author_name(self) -> str: + """ + Renvoie le nom de l'auteur⋅rice du message, en fonction de son rôle dans l'organisation + dans le cadre d'un⋅e bénévole, ou de son équipe dans le cadre d'un⋅e participant⋅e. + """ registration = self.author.registration author_name = f"{self.author.first_name} {self.author.last_name}" if registration.is_volunteer: if registration.is_admin: + # Les administrateur⋅rices ont le suffixe (CNO) author_name += " (CNO)" if self.channel.pool: if registration == self.channel.pool.jury_president: + # Læ président⋅e de jury de la poule a le suffixe (P. jury) author_name += " (P. jury)" elif registration in self.channel.pool.juries.all(): + # Les juré⋅es de la poule ont le suffixe (Juré⋅e) author_name += " (Juré⋅e)" elif registration in self.channel.pool.tournament.organizers.all(): + # Les organisateur⋅rices du tournoi ont le suffixe (CRO) author_name += " (CRO)" else: + # Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole) author_name += " (Bénévole)" elif self.channel.tournament: if registration in self.channel.tournament.organizers.all(): + # Les organisateur⋅rices du tournoi ont le suffixe (CRO) author_name += " (CRO)" elif any([registration.id == pool.jury_president for pool in self.channel.tournament.pools.all()]): + # Les président⋅es de jury des poules ont le suffixe (P. jury) + # mentionnant l'ensemble des poules qu'iels président pools = ", ".join([pool.short_name for pool in self.channel.tournament.pools.all() if pool.jury_president == registration]) author_name += f" (P. jury {pools})" elif any([pool.juries.contains(registration) for pool in self.channel.tournament.pools.all()]): + # Les juré⋅es des poules ont le suffixe (Juré⋅e) + # mentionnant l'ensemble des poules auxquelles iels participent pools = ", ".join([pool.short_name for pool in self.channel.tournament.pools.all() if pool.juries.acontains(registration)]) author_name += f" (Juré⋅e {pools})" else: + # Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole) author_name += " (Bénévole)" else: if registration.organized_tournaments.exists(): + # Les organisateur⋅rices de tournois ont le suffixe (CRO) mentionnant les tournois organisés tournaments = ", ".join([tournament.name for tournament in registration.organized_tournaments.all()]) author_name += f" (CRO {tournaments})" if Pool.objects.filter(jury_president=registration).exists(): + # Les président⋅es de jury ont le suffixe (P. jury) mentionnant les tournois présidés tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct() tournaments = ", ".join([tournament.name for tournament in tournaments]) author_name += f" (P. jury {tournaments})" elif registration.jury_in.exists(): + # Les juré⋅es ont le suffixe (Juré⋅e) mentionnant les tournois auxquels iels participent tournaments = Tournament.objects.filter(pools__juries=registration).distinct() tournaments = ", ".join([tournament.name for tournament in tournaments]) author_name += f" (Juré⋅e {tournaments})" else: if registration.team_id: + # Le trigramme de l'équipe de læ participant⋅e est ajouté en suffixe team = Team.objects.get(id=registration.team_id) author_name += f" ({team.trigram})" else: @@ -264,7 +352,11 @@ class Message(models.Model): return author_name - async def aget_author_name(self): + async def aget_author_name(self) -> str: + """ + Fonction asynchrone pour récupérer le nom de l'auteur⋅rice du message. + Voir `get_author_name` pour plus de détails. + """ return await sync_to_async(self.get_author_name)() class Meta: diff --git a/chat/signals.py b/chat/signals.py index 72056e3..81db08c 100644 --- a/chat/signals.py +++ b/chat/signals.py @@ -7,8 +7,14 @@ from tfjm.permissions import PermissionType def create_tournament_channels(instance: Tournament, **_kwargs): + """ + Lorsqu'un tournoi est créé, on crée les canaux de chat associés. + On crée notamment un canal d'annonces (accessible en écriture uniquement aux orgas), + un canal général, un de détente, un pour les juré⋅es et un pour les président⋅es de jury. + """ tournament = instance + # Création du canal « Tournoi - Annonces » Channel.objects.update_or_create( name=f"{tournament.name} - Annonces", defaults=dict( @@ -19,6 +25,7 @@ def create_tournament_channels(instance: Tournament, **_kwargs): ), ) + # Création du canal « Tournoi - Général » Channel.objects.update_or_create( name=f"{tournament.name} - Général", defaults=dict( @@ -29,6 +36,7 @@ def create_tournament_channels(instance: Tournament, **_kwargs): ), ) + # Création du canal « Tournoi - Détente » Channel.objects.update_or_create( name=f"{tournament.name} - Détente", defaults=dict( @@ -39,6 +47,7 @@ def create_tournament_channels(instance: Tournament, **_kwargs): ), ) + # Création du canal « Tournoi - Juré⋅es » Channel.objects.update_or_create( name=f"{tournament.name} - Juré⋅es", defaults=dict( @@ -50,6 +59,7 @@ def create_tournament_channels(instance: Tournament, **_kwargs): ) if tournament.remote: + # Création du canal « Tournoi - Président⋅es de jury » dans le cas d'un tournoi distanciel Channel.objects.update_or_create( name=f"{tournament.name} - Président⋅es de jury", defaults=dict( @@ -62,10 +72,17 @@ def create_tournament_channels(instance: Tournament, **_kwargs): def create_pool_channels(instance: Pool, **_kwargs): + """ + Lorsqu'une poule est créée, on crée les canaux de chat associés. + On crée notamment un canal pour les membres de la poule et un pour les juré⋅es. + Cela ne concerne que les tournois distanciels. + """ pool = instance tournament = pool.tournament if tournament.remote: + # Dans le cadre d'un tournoi distanciel, on crée un canal pour les membres de la poule + # et un pour les juré⋅es de la poule. Channel.objects.update_or_create( name=f"{tournament.name} - Poule {pool.short_name}", defaults=dict( @@ -88,6 +105,9 @@ def create_pool_channels(instance: Pool, **_kwargs): def create_team_channel(instance: Participation, **_kwargs): + """ + Lorsqu'une équipe est validée, on crée un canal de chat associé. + """ if instance.valid: Channel.objects.update_or_create( name=f"Équipe {instance.team.trigram}", diff --git a/chat/static/chat.js b/chat/static/chat.js index c4442c8..5d2b9bc 100644 --- a/chat/static/chat.js +++ b/chat/static/chat.js @@ -1,58 +1,85 @@ (async () => { - // check notification permission - // This is useful to alert people that they should do something + // Vérification de la permission pour envoyer des notifications + // C'est utile pour prévenir les utilisateur⋅rices de l'arrivée de nouveaux messages les mentionnant await Notification.requestPermission() })() -const MAX_MESSAGES = 50 +const MAX_MESSAGES = 50 // Nombre maximal de messages à charger à la fois -const channel_categories = ['general', 'tournament', 'team', 'private'] -let channels = {} -let messages = {} -let selected_channel_id = null +const channel_categories = ['general', 'tournament', 'team', 'private'] // Liste des catégories de canaux +let channels = {} // Liste des canaux disponibles +let messages = {} // Liste des messages reçus par canal +let selected_channel_id = null // Canal courant /** - * Display a new notification with the given title and the given body. - * @param title The title of the notification - * @param body The body of the notification - * @param timeout The time (in milliseconds) after that the notification automatically closes. 0 to make indefinite. Default to 5000 ms. + * Affiche une nouvelle notification avec le titre donné et le contenu donné. + * @param title Le titre de la notification + * @param body Le contenu de la notification + * @param timeout La durée (en millisecondes) après laquelle la notification se ferme automatiquement. + * Définir à 0 (défaut) pour la rendre infinie. * @return Notification */ -function showNotification(title, body, timeout = 5000) { +function showNotification(title, body, timeout = 0) { Notification.requestPermission().then((status) => { - if (status === 'granted') - new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"}) + if (status === 'granted') { + // On envoie la notification que si la permission a été donnée + let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"}) + if (timeout > 0) + setTimeout(() => notif.close(), timeout) + return notif + } }) } +/** + * Sélectionne le canal courant à afficher sur l'interface de chat. + * Va alors définir le canal courant et mettre à jour les messages affichés. + * @param channel_id L'identifiant du canal à afficher. + */ function selectChannel(channel_id) { let channel = channels[channel_id] if (!channel) { + // Le canal n'existe pas console.error('Channel not found:', channel_id) return } selected_channel_id = channel_id + // On stocke dans le stockage local l'identifiant du canal + // pour pouvoir rouvrir le dernier canal ouvert dans le futur localStorage.setItem('chat.last-channel-id', channel_id) + // Définition du titre du contenu let channelTitle = document.getElementById('channel-title') - channelTitle.innerText = channel['name'] + channelTitle.innerText = channel.name + // Si on a pas le droit d'écrire dans le canal, on désactive l'input de message + // On l'active sinon let messageInput = document.getElementById('input-message') - messageInput.disabled = !channel['write_access'] + messageInput.disabled = !channel.write_access + // On redessine la liste des messages à partir des messages stockés redrawMessages() } +/** + * On récupère le message écrit par l'utilisateur⋅rice dans le champ de texte idoine, + * et on le transmet ensuite au serveur. + * Il ne s'affiche pas instantanément sur l'interface, + * mais seulement une fois que le serveur aura validé et retransmis le message. + */ function sendMessage() { + // Récupération du message à envoyer let messageInput = document.getElementById('input-message') let message = messageInput.value + // On efface le champ de texte après avoir récupéré le message messageInput.value = '' if (!message) { return } + // Envoi du message au serveur socket.send(JSON.stringify({ 'type': 'send_message', 'channel_id': selected_channel_id, @@ -60,19 +87,30 @@ function sendMessage() { })) } +/** + * Met à jour la liste des canaux disponibles, à partir de la liste récupérée du serveur. + * @param new_channels La liste des canaux à afficher. + * Chaque canal doit être un objet avec les clés `id`, `name`, `category` + * `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal, + * son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus. + */ function setChannels(new_channels) { channels = {} - let categoryLists = {} for (let category of channel_categories) { - categoryLists[category] = document.getElementById(`nav-${category}-channels-tab`) - categoryLists[category].innerHTML = '' - categoryLists[category].parentElement.classList.add('d-none') + // On commence par vider la liste des canaux sélectionnables + let categoryList = document.getElementById(`nav-${category}-channels-tab`) + categoryList.innerHTML = '' + categoryList.parentElement.classList.add('d-none') } for (let channel of new_channels) - addChannel(channel, categoryLists) + // On ajoute chaque canal à la liste des canaux + addChannel(channel) if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { + // Si aucun canal n'a encore été sélectionné et qu'il y a des canaux disponibles, + // on commence par vérifier si on a stocké un canal précédemment sélectionné et on l'affiche si c'est le cas + // Sinon, on affiche le premier canal disponible let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id')) if (last_channel_id && channels[last_channel_id]) selectChannel(last_channel_id) @@ -81,67 +119,123 @@ function setChannels(new_channels) { } } -async function addChannel(channel, categoryLists) { - channels[channel['id']] = channel - if (!messages[channel['id']]) - messages[channel['id']] = new Map() +/** + * Ajoute un canal à la liste des canaux disponibles. + * @param channel Le canal à ajouter. Doit être un objet avec les clés `id`, `name`, `category`, + * `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal, + * son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus. + */ +async function addChannel(channel) { + channels[channel.id] = channel + if (!messages[channel.id]) + messages[channel.id] = new Map() - let categoryList = categoryLists[channel['category']] + // On récupère la liste des canaux de la catégorie concernée + let categoryList = document.getElementById(`nav-${channel.category}-channels-tab`) + // On la rend visible si elle ne l'était pas déjà categoryList.parentElement.classList.remove('d-none') + // On crée un nouvel élément de liste pour la catégorie concernant le canal let navItem = document.createElement('li') navItem.classList.add('list-group-item', 'tab-channel') - navItem.id = `tab-channel-${channel['id']}` + navItem.id = `tab-channel-${channel.id}` navItem.setAttribute('data-bs-dismiss', 'offcanvas') - navItem.onclick = () => selectChannel(channel['id']) + navItem.onclick = () => selectChannel(channel.id) categoryList.appendChild(navItem) + // L'élément est cliquable afin de sélectionner le canal let channelButton = document.createElement('button') channelButton.classList.add('nav-link') channelButton.type = 'button' - channelButton.innerText = channel['name'] + channelButton.innerText = channel.name navItem.appendChild(channelButton) + // Affichage du nombre de messages non lus let unreadBadge = document.createElement('span') unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2') - unreadBadge.id = `unread-messages-${channel['id']}` + unreadBadge.id = `unread-messages-${channel.id}` unreadBadge.innerText = channel.unread_messages || 0 if (!channel.unread_messages) unreadBadge.classList.add('d-none') channelButton.appendChild(unreadBadge) + // Si on veut trier les canaux par nombre décroissant de messages non lus, + // on définit l'ordre de l'élément (propriété CSS) en fonction du nombre de messages non lus if (document.getElementById('sort-by-unread-switch').checked) navItem.style.order = `${-channel.unread_messages}` - fetchMessages(channel['id']) + // On demande enfin à récupérer les derniers messages du canal en question afin de les stocker / afficher + fetchMessages(channel.id) } +/** + * Un⋅e utilisateur⋅rice a envoyé un message, qui a été retransmis par le serveur. + * On le stocke alors et on l'affiche sur l'interface si nécessaire. + * On affiche également une notification si le message contient une mention pour tout le monde. + * @param message Le message qui a été transmis. Doit être un objet avec + * les clés `id`, `channel_id`, `author`, `author_id`, `content` et `timestamp`, + * correspondant à l'identifiant du message, du canal, le nom de l'auteur⋅rice et l'heure d'envoi. + */ function receiveMessage(message) { + // On vérifie si la barre de défilement est tout en bas let scrollableContent = document.getElementById('chat-messages') let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1 - messages[message['channel_id']].set(message['id'], message) - redrawMessages() + // On stocke le message dans la liste des messages du canal concerné + // et on redessine les messages affichés si on est dans le canal concerné + messages[message.channel_id].set(message.id, message) + if (message.channel_id === selected_channel_id) + redrawMessages() - // Scroll to bottom if the user was already at the bottom + // Si la barre de défilement était tout en bas, alors on la remet tout en bas après avoir redessiné les messages if (isScrolledToBottom) scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight - if (message['content'].includes("@everyone")) - showNotification(channels[message['channel_id']]['name'], `${message['author']} : ${message['content']}`) + // On ajoute un à la liste des messages non lus du canal (il pourra être lu plus tard) + updateUnreadBadge(message.channel_id, channels[message.channel_id].unread_messages + 1) + + // Si le message contient une mention à @everyone, alors on envoie une notification (si la permission est donnée) + if (message.content.includes("@everyone")) + showNotification(channels[message.channel_id].name, `${message.author} : ${message.content}`) + + // On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour + // Permettant entre autres de marquer le message comme lu si c'est le cas + document.getElementById('message-list').dispatchEvent(new CustomEvent('updatemessages')) } +/** + * Un message a été modifié, et le serveur nous a transmis les nouvelles informations. + * @param data Le nouveau message qui a été modifié. + */ function editMessage(data) { - messages[data['channel_id']].get(data['id'])['content'] = data['content'] - redrawMessages() + // On met à jour le contenu du message + messages[data.channel_id].get(data.id).content = data.content + // Si le message appartient au canal courant, on redessine les messages + if (data.channel_id === selected_channel_id) + redrawMessages() } +/** + * Un message a été supprimé, et le serveur nous a transmis les informations. + * @param data Le message qui a été supprimé. + */ function deleteMessage(data) { - messages[data['channel_id']].delete(data['id']) - redrawMessages() + // On supprime le message de la liste des messages du canal concerné + messages[data.channel_id].delete(data.id) + // Si le message appartient au canal courant, on redessine les messages + if (data.channel_id === selected_channel_id) + redrawMessages() } +/** + * Demande au serveur de récupérer les messages du canal donné. + * @param channel_id L'identifiant du canal dont on veut récupérer les messages. + * @param offset Le décalage à partir duquel on veut récupérer les messages, + * correspond au nombre de messages en mémoire. + * @param limit Le nombre maximal de messages à récupérer. + */ function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) { + // Envoi de la requête au serveur avec les différents paramètres socket.send(JSON.stringify({ 'type': 'fetch_messages', 'channel_id': channel_id, @@ -150,146 +244,240 @@ function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) { })) } +/** + * Demande au serveur de récupérer les messages précédents du canal courant. + * Par défaut, on récupère `MAX_MESSAGES` messages avant tous ceux qui ont été reçus sur ce canal. + */ function fetchPreviousMessages() { let channel_id = selected_channel_id let offset = messages[channel_id].size fetchMessages(channel_id, offset, MAX_MESSAGES) } +/** + * L'utilisateur⋅rice a demandé à récupérer une partie des messages d'un canal. + * Cette fonction est alors appelée lors du retour du serveur. + * @param data Dictionnaire contenant l'identifiant du canal concerné, et la liste des messages récupérés. + */ function receiveFetchedMessages(data) { - let channel_id = data['channel_id'] - let new_messages = data['messages'] + // Récupération du canal concerné ainsi que des nouveaux messages à mémoriser + let channel_id = data.channel_id + let new_messages = data.messages if (!messages[channel_id]) messages[channel_id] = new Map() + // Ajout des nouveaux messages à la liste des messages du canal for (let message of new_messages) - messages[channel_id].set(message['id'], message) + messages[channel_id].set(message.id, message) - // Sort messages by timestamp + // On trie les messages reçus par date et heure d'envoi messages[channel_id] = new Map([...messages[channel_id].values()] - .sort((a, b) => new Date(a['timestamp']) - new Date(b['timestamp'])) - .map(message => [message['id'], message])) + .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)) + .map(message => [message.id, message])) - redrawMessages() + // Enfin, si le canal concerné est le canal courant, on redessine les messages + if (channel_id === selected_channel_id) + redrawMessages() } +/** + * L'utilisateur⋅rice a indiqué au serveur que des messages ont été lus. + * Cette fonction est appelée en retour, pour confirmer, et stocke quels messages ont été lus + * et combien de messages sont non lus par canal. + * @param data Dictionnaire contenant une clé `read`, contenant la liste des identifiants des messages + * marqués comme lus avec leur canal respectif, et une clé `unread_messages` contenant le nombre + * de messages non lus par canal. + */ function markMessageAsRead(data) { - for (let message of data['messages']) { - let stored_message = messages[message['channel_id']].get(message['id']) + for (let message of data.messages) { + // Récupération du message à marquer comme lu + let stored_message = messages[message.channel_id].get(message.id) + // Marquage du message comme lu if (stored_message) - stored_message['read'] = true + stored_message.read = true } - redrawMessages() - updateUnreadBadges(data['unread_messages']) + // Actualisation des badges contenant le nombre de messages non lus par canal + updateUnreadBadges(data.unread_messages) } +/** + * Mise à jour des badges contenant le nombre de messages non lus par canal. + * @param unreadMessages Dictionnaire des nombres de messages non lus par canal (identifiés par leurs identifiants) + */ function updateUnreadBadges(unreadMessages) { + for (let channel of Object.values(channels)) { + // Récupération du nombre de messages non lus pour le canal en question et mise à jour du badge pour ce canal + updateUnreadBadge(channel.id, unreadMessages[channel.id] || 0) + } +} + +/** + * Mise à jour du badge du nombre de messages non lus d'un canal. + * Actualise sa visibilité. + * @param channel_id Identifiant du canal concerné. + * @param unreadMessagesCount Nombre de messages non lus du canal. + */ +function updateUnreadBadge(channel_id, unreadMessagesCount = 0) { + // Vaut true si on veut trier les canaux par nombre de messages non lus ou non const sortByUnread = document.getElementById('sort-by-unread-switch').checked - for (let channel of Object.values(channels)) { - let unreadMessagesChannel = unreadMessages[channel['id']] || 0 - channel.unread_messages = unreadMessagesChannel + // Récupération du canal concerné + let channel = channels[channel_id] - let unreadBadge = document.getElementById(`unread-messages-${channel['id']}`) - unreadBadge.innerText = unreadMessagesChannel - if (unreadMessagesChannel) - unreadBadge.classList.remove('d-none') - else - unreadBadge.classList.add('d-none') + // Récupération du nombre de messages non lus pour le canal en question, que l'on stocke + channel.unread_messages = unreadMessagesCount - if (sortByUnread) - document.getElementById(`tab-channel-${channel['id']}`).style.order = `${-unreadMessagesChannel}` - } + // On met à jour le badge du canal contenant le nombre de messages non lus + let unreadBadge = document.getElementById(`unread-messages-${channel.id}`) + unreadBadge.innerText = unreadMessagesCount.toString() + + // Le badge est visible si et seulement si il y a au moins un message non lu + if (unreadMessagesCount) + unreadBadge.classList.remove('d-none') + else + unreadBadge.classList.add('d-none') + + // S'il faut trier les canaux par nombre de messages non lus, on ajoute la propriété CSS correspondante + if (sortByUnread) + document.getElementById(`tab-channel-${channel.id}`).style.order = `${-unreadMessagesCount}` } +/** + * La création d'un canal privé entre deux personnes a été demandée. + * Cette fonction est appelée en réponse du serveur. + * Le canal est ajouté à la liste s'il est nouveau, et automatiquement sélectionné. + * @param data Dictionnaire contenant une unique clé `channel` correspondant aux informations du canal privé. + */ function startPrivateChat(data) { - let channel = data['channel'] + // Récupération du canal + let channel = data.channel if (!channel) { console.error('Private chat not found:', data) return } - if (!channels[channel['id']]) { - channels[channel['id']] = channel - messages[channel['id']] = new Map() - setChannels(Object.values(channels)) + if (!channels[channel.id]) { + // Si le canal n'est pas récupéré, on l'ajoute à la liste + channels[channel.id] = channel + messages[channel.id] = new Map() + addChannel(channel) } - selectChannel(channel['id']) + // Sélection immédiate du canal privé + selectChannel(channel.id) } +/** + * Met à jour le composant correspondant à la liste des messages du canal sélectionné. + * Le conteneur est d'abord réinitialisé, puis les messages sont affichés un à un à partir de ceux stockés. + */ function redrawMessages() { + // Récupération du composant HTML
$1') // Code - .replaceAll(/(https?:\/\/\S+)/g, '$1') // Links + .replaceAll(/(https?:\/\/\S+)/g, '$1') // Liens htmlLines.push(htmlLine) } + // On joint enfin toutes les lignes par des balises de saut de ligne return htmlLines.join('