mirror of
				https://gitlab.com/animath/si/plateforme.git
				synced 2025-10-31 22:24:30 +01:00 
			
		
		
		
	Add french comments on chat application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
This commit is contained in:
		| @@ -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',) | ||||
|   | ||||
| @@ -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']}) | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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}", | ||||
|   | ||||
| @@ -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 <ul> correspondant à la liste des messages affichés | ||||
|     let messageList = document.getElementById('message-list') | ||||
|     // On commence par le vider | ||||
|     messageList.innerHTML = '' | ||||
|  | ||||
|     let lastMessage = null | ||||
|     let lastContentDiv = null | ||||
|  | ||||
|     for (let message of messages[selected_channel_id].values()) { | ||||
|         if (lastMessage && lastMessage['author'] === message['author']) { | ||||
|             let lastTimestamp = new Date(lastMessage['timestamp']) | ||||
|             let newTimestamp = new Date(message['timestamp']) | ||||
|         if (lastMessage && lastMessage.author === message.author) { | ||||
|             // Si le message est écrit par læ même auteur⋅rice que le message précédent, | ||||
|             // alors on les groupe ensemble | ||||
|             let lastTimestamp = new Date(lastMessage.timestamp) | ||||
|             let newTimestamp = new Date(message.timestamp) | ||||
|             if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) { | ||||
|                 // Les messages sont groupés uniquement s'il y a une différence maximale de 10 minutes | ||||
|                 // entre le premier message du groupe et celui en étude | ||||
|                 // On ajoute alors le contenu du message en cours dans le dernier div de message | ||||
|                 let messageContentDiv = document.createElement('div') | ||||
|                 messageContentDiv.classList.add('message') | ||||
|                 messageContentDiv.setAttribute('data-message-id', message['id']) | ||||
|                 messageContentDiv.setAttribute('data-message-id', message.id) | ||||
|                 lastContentDiv.appendChild(messageContentDiv) | ||||
|                 let messageContentSpan = document.createElement('span') | ||||
|                 messageContentSpan.innerHTML = markdownToHTML(message['content']) | ||||
|                 messageContentSpan.innerHTML = markdownToHTML(message.content) | ||||
|                 messageContentDiv.appendChild(messageContentSpan) | ||||
|  | ||||
|                 // Enregistrement du menu contextuel pour le message permettant la modification, la suppression | ||||
|                 // et l'envoi de messages privés | ||||
|                 registerMessageContextMenu(message, messageContentDiv, messageContentSpan) | ||||
|                 continue | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Création de l'élément <li> pour le bloc de messages | ||||
|         let messageElement = document.createElement('li') | ||||
|         messageElement.classList.add('list-group-item') | ||||
|         messageList.appendChild(messageElement) | ||||
|  | ||||
|         // Ajout d'un div contenant le nom de l'auteur⋅rice du message ainsi que la date et heure d'envoi | ||||
|         let authorDiv = document.createElement('div') | ||||
|         messageElement.appendChild(authorDiv) | ||||
|  | ||||
|         // Ajout du nom de l'auteur⋅rice du message | ||||
|         let authorSpan = document.createElement('span') | ||||
|         authorSpan.classList.add('text-muted', 'fw-bold') | ||||
|         authorSpan.innerText = message['author'] | ||||
|         authorSpan.innerText = message.author | ||||
|         authorDiv.appendChild(authorSpan) | ||||
|  | ||||
|         registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan) | ||||
|  | ||||
|         // Ajout de la date du message | ||||
|         let dateSpan = document.createElement('span') | ||||
|         dateSpan.classList.add('text-muted', 'float-end') | ||||
|         dateSpan.innerText = new Date(message['timestamp']).toLocaleString() | ||||
|         dateSpan.innerText = new Date(message.timestamp).toLocaleString() | ||||
|         authorDiv.appendChild(dateSpan) | ||||
|  | ||||
|         // Enregistrement du menu contextuel pour le message permettant l'envoi de messages privés à l'auteur⋅rice | ||||
|         registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan) | ||||
|  | ||||
|         let contentDiv = document.createElement('div') | ||||
|         messageElement.appendChild(contentDiv) | ||||
|  | ||||
|         // Ajout du contenu du message | ||||
|         // Le contenu est mis dans un span lui-même inclus dans un div, | ||||
|         let messageContentDiv = document.createElement('div') | ||||
|         messageContentDiv.classList.add('message') | ||||
|         messageContentDiv.setAttribute('data-message-id', message['id']) | ||||
|         messageContentDiv.setAttribute('data-message-id', message.id) | ||||
|         contentDiv.appendChild(messageContentDiv) | ||||
|         let messageContentSpan = document.createElement('span') | ||||
|         messageContentSpan.innerHTML = markdownToHTML(message['content']) | ||||
|         messageContentSpan.innerHTML = markdownToHTML(message.content) | ||||
|         messageContentDiv.appendChild(messageContentSpan) | ||||
|  | ||||
|         // Enregistrement du menu contextuel pour le message permettant la modification, la suppression | ||||
|         // et l'envoi de messages privés | ||||
|         registerMessageContextMenu(message, messageContentDiv, messageContentSpan) | ||||
|  | ||||
|         lastMessage = message | ||||
|         lastContentDiv = contentDiv | ||||
|     } | ||||
|  | ||||
|     // Le bouton « Afficher les messages précédents » est affiché si et seulement si | ||||
|     // il y a des messages à récupérer (c'est-à-dire si le nombre de messages récupérés est un multiple de MAX_MESSAGES) | ||||
|     let fetchMoreButton = document.getElementById('fetch-previous-messages') | ||||
|     if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0) | ||||
|         fetchMoreButton.classList.add('d-none') | ||||
|     else | ||||
|         fetchMoreButton.classList.remove('d-none') | ||||
|  | ||||
|     // On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour | ||||
|     // Permettant entre autres de marquer les messages visibles comme lus si c'est le cas | ||||
|     messageList.dispatchEvent(new CustomEvent('updatemessages')) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Convertit un texte écrit en Markdown en HTML. | ||||
|  * Les balises Markdown suivantes sont supportées : | ||||
|  * - Souligné : `_texte_` | ||||
|  * - Gras : `**texte**` | ||||
|  * - Italique : `*texte*` | ||||
|  * - Code : `` `texte` `` | ||||
|  * - Les liens sont automatiquement convertis | ||||
|  * - Les esperluettes, guillemets et chevrons sont échappés. | ||||
|  * @param text Le texte écrit en Markdown. | ||||
|  * @return {string} Le texte converti en HTML. | ||||
|  */ | ||||
| function markdownToHTML(text) { | ||||
|     // On échape certains caractères spéciaux (esperluettes, chevrons, guillemets) | ||||
|     let safeText = text.replace(/&/g, "&") | ||||
|          .replace(/</g, "<") | ||||
|          .replace(/>/g, ">") | ||||
| @@ -298,17 +486,22 @@ function markdownToHTML(text) { | ||||
|     let lines = safeText.split('\n') | ||||
|     let htmlLines = [] | ||||
|     for (let line of lines) { | ||||
|         // Pour chaque ligne, on remplace le Markdown par un équivalent HTML (pour ce qui est supporté) | ||||
|         let htmlLine = line | ||||
|             .replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>')  // Underline | ||||
|             .replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>')  // Bold | ||||
|             .replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>')  // Italic | ||||
|             .replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>')  // Souligné | ||||
|             .replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>')  // Gras | ||||
|             .replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>')  // Italique | ||||
|             .replaceAll(/`(.*)`/gim, '<pre>$1</pre>')  // Code | ||||
|             .replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>')  // Links | ||||
|             .replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>')  // Liens | ||||
|         htmlLines.push(htmlLine) | ||||
|     } | ||||
|     // On joint enfin toutes les lignes par des balises de saut de ligne | ||||
|     return htmlLines.join('<br>') | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Ferme toutes les popovers ouvertes. | ||||
|  */ | ||||
| function removeAllPopovers() { | ||||
|     for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) { | ||||
|         let instance = bootstrap.Popover.getInstance(popover) | ||||
| @@ -317,39 +510,70 @@ function removeAllPopovers() { | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Enregistrement du menu contextuel pour un⋅e auteur⋅rice de message, | ||||
|  * donnant la possibilité d'envoyer un message privé. | ||||
|  * @param message Le message écrit par l'auteur⋅rice du bloc en question. | ||||
|  * @param div Le bloc contenant le nom de l'auteur⋅rice et de la date d'envoi du message. | ||||
|  *            Un clic droit sur lui affichera le menu contextuel. | ||||
|  * @param span Le span contenant le nom de l'auteur⋅rice. | ||||
|  *             Il désignera l'emplacement d'affichage du popover. | ||||
|  */ | ||||
| function registerSendPrivateMessageContextMenu(message, div, span) { | ||||
|     // Enregistrement de l'écouteur d'événement pour le clic droit | ||||
|     div.addEventListener('contextmenu', (menu_event) => { | ||||
|         // On empêche le menu traditionnel de s'afficher | ||||
|         menu_event.preventDefault() | ||||
|         // On retire toutes les popovers déjà ouvertes | ||||
|         removeAllPopovers() | ||||
|  | ||||
|         // On crée le popover contenant le lien pour envoyer un message privé, puis on l'affiche | ||||
|         const popover = bootstrap.Popover.getOrCreateInstance(span, { | ||||
|             'title': message['author'], | ||||
|             'content': `<a id="send-private-message-link-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`, | ||||
|             'title': message.author, | ||||
|             'content': `<a id="send-private-message-link-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`, | ||||
|             'html': true, | ||||
|         }) | ||||
|         popover.show() | ||||
|  | ||||
|         document.getElementById('send-private-message-link-' + message['id']).addEventListener('click', event => { | ||||
|         // Lorsqu'on clique sur le lien, on ferme le popover | ||||
|         // et on demande à ouvrir le canal privé avec l'auteur⋅rice du message | ||||
|         document.getElementById('send-private-message-link-' + message.id).addEventListener('click', event => { | ||||
|             event.preventDefault() | ||||
|             popover.dispose() | ||||
|             socket.send(JSON.stringify({ | ||||
|                 'type': 'start_private_chat', | ||||
|                 'user_id': message['author_id'], | ||||
|                 'user_id': message.author_id, | ||||
|             })) | ||||
|         }) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Enregistrement du menu contextuel pour un message, | ||||
|  * donnant la possibilité de modifier ou de supprimer le message, ou d'envoyer un message privé à l'auteur⋅rice. | ||||
|  * @param message Le message en question. | ||||
|  * @param div Le bloc contenant le contenu du message. | ||||
|  *            Un clic droit sur lui affichera le menu contextuel. | ||||
|  * @param span Le span contenant le contenu du message. | ||||
|  *             Il désignera l'emplacement d'affichage du popover. | ||||
|  */ | ||||
| function registerMessageContextMenu(message, div, span) { | ||||
|     // Enregistrement de l'écouteur d'événement pour le clic droit | ||||
|     div.addEventListener('contextmenu', (menu_event) => { | ||||
|         // On empêche le menu traditionnel de s'afficher | ||||
|         menu_event.preventDefault() | ||||
|         // On retire toutes les popovers déjà ouvertes | ||||
|         removeAllPopovers() | ||||
|         let content = `<a id="send-private-message-link-msg-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>` | ||||
|  | ||||
|         let has_right_to_edit = message['author_id'] === USER_ID || IS_ADMIN | ||||
|         // On crée le popover contenant les liens pour modifier, supprimer le message ou envoyer un message privé. | ||||
|         let content = `<a id="send-private-message-link-msg-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>` | ||||
|  | ||||
|         // On ne peut modifier ou supprimer un message que si on est l'auteur⋅rice ou que l'on est administrateur⋅rice. | ||||
|         let has_right_to_edit = message.author_id === USER_ID || IS_ADMIN | ||||
|         if (has_right_to_edit) { | ||||
|             content += `<hr class="my-1">` | ||||
|             content += `<a id="edit-message-${message['id']}" class="nav-link" href="#" tabindex="0">Modifier</a>` | ||||
|             content += `<a id="delete-message-${message['id']}" class="nav-link" href="#" tabindex="0">Supprimer</a>` | ||||
|             content += `<a id="edit-message-${message.id}" class="nav-link" href="#" tabindex="0">Modifier</a>` | ||||
|             content += `<a id="delete-message-${message.id}" class="nav-link" href="#" tabindex="0">Supprimer</a>` | ||||
|         } | ||||
|  | ||||
|         const popover = bootstrap.Popover.getOrCreateInstance(span, { | ||||
| @@ -359,36 +583,48 @@ function registerMessageContextMenu(message, div, span) { | ||||
|         }) | ||||
|         popover.show() | ||||
|  | ||||
|         document.getElementById('send-private-message-link-msg-' + message['id']).addEventListener('click', event => { | ||||
|         // Lorsqu'on clique sur le lien d'envoi de message privé, on ferme le popover | ||||
|         // et on demande à ouvrir le canal privé avec l'auteur⋅rice du message | ||||
|         document.getElementById('send-private-message-link-msg-' + message.id).addEventListener('click', event => { | ||||
|             event.preventDefault() | ||||
|             popover.dispose() | ||||
|             socket.send(JSON.stringify({ | ||||
|                 'type': 'start_private_chat', | ||||
|                 'user_id': message['author_id'], | ||||
|                 'user_id': message.author_id, | ||||
|             })) | ||||
|         }) | ||||
|  | ||||
|         if (has_right_to_edit) { | ||||
|             document.getElementById('edit-message-' + message['id']).addEventListener('click', event => { | ||||
|             // Si on a le droit de modifier ou supprimer le message, on enregistre les écouteurs d'événements | ||||
|             // Le bouton de modification de message ouvre une boîte de dialogue pour modifier le message | ||||
|             document.getElementById('edit-message-' + message.id).addEventListener('click', event => { | ||||
|                 event.preventDefault() | ||||
|                 // Fermeture du popover | ||||
|                 popover.dispose() | ||||
|                 let new_message = prompt("Modifier le message", message['content']) | ||||
|  | ||||
|                 // Ouverture d'une boîte de diaologue afin de modifier le message | ||||
|                 let new_message = prompt("Modifier le message", message.content) | ||||
|                 if (new_message) { | ||||
|                     // Si le message a été modifié, on envoie la demande de modification au serveur | ||||
|                     socket.send(JSON.stringify({ | ||||
|                         'type': 'edit_message', | ||||
|                         'message_id': message['id'], | ||||
|                         'message_id': message.id, | ||||
|                         'content': new_message, | ||||
|                     })) | ||||
|                 } | ||||
|             }) | ||||
|  | ||||
|             document.getElementById('delete-message-' + message['id']).addEventListener('click', event => { | ||||
|             // Le bouton de suppression de message demande une confirmation avant de supprimer le message | ||||
|             document.getElementById('delete-message-' + message.id).addEventListener('click', event => { | ||||
|                 event.preventDefault() | ||||
|                 // Fermeture du popover | ||||
|                 popover.dispose() | ||||
|                 if (confirm(`Supprimer le message ?\n${message['content']}`)) { | ||||
|  | ||||
|                 // Demande de confirmation avant de supprimer le message | ||||
|                 if (confirm(`Supprimer le message ?\n${message.content}`)) { | ||||
|                     socket.send(JSON.stringify({ | ||||
|                         'type': 'delete_message', | ||||
|                         'message_id': message['id'], | ||||
|                         'message_id': message.id, | ||||
|                     })) | ||||
|                 } | ||||
|             }) | ||||
| @@ -396,14 +632,21 @@ function registerMessageContextMenu(message, div, span) { | ||||
|     }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Passe le chat en version plein écran, ou l'inverse si c'est déjà le cas. | ||||
|  */ | ||||
| function toggleFullscreen() { | ||||
|     let chatContainer = document.getElementById('chat-container') | ||||
|     if (!chatContainer.getAttribute('data-fullscreen')) { | ||||
|         // Le chat n'est pas en plein écran. | ||||
|         // On le passe en plein écran en le plaçant en avant plan en position absolue | ||||
|         // prenant toute la hauteur et toute la largeur | ||||
|         chatContainer.setAttribute('data-fullscreen', 'true') | ||||
|         chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') | ||||
|         window.history.replaceState({}, null, `?fullscreen=1`) | ||||
|     } | ||||
|     else { | ||||
|         // Le chat est déjà en plein écran. On retire les tags CSS correspondant au plein écran. | ||||
|         chatContainer.removeAttribute('data-fullscreen') | ||||
|         chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') | ||||
|         window.history.replaceState({}, null, `?fullscreen=0`) | ||||
| @@ -411,34 +654,45 @@ function toggleFullscreen() { | ||||
| } | ||||
|  | ||||
| document.addEventListener('DOMContentLoaded', () => { | ||||
|     // Lorsqu'on effectue le moindre clic, on ferme les éventuelles popovers ouvertes | ||||
|     document.addEventListener('click', removeAllPopovers) | ||||
|  | ||||
|     // Lorsqu'on change entre le tri des canaux par ordre alphabétique et par nombre de messages non lus, | ||||
|     // on met à jour l'ordre des canaux | ||||
|     document.getElementById('sort-by-unread-switch').addEventListener('change', event => { | ||||
|         const sortByUnread = event.target.checked | ||||
|         for (let channel of Object.values(channels)) { | ||||
|             let item = document.getElementById(`tab-channel-${channel['id']}`) | ||||
|             let item = document.getElementById(`tab-channel-${channel.id}`) | ||||
|             if (sortByUnread) | ||||
|                 // Si on trie par nombre de messages non lus, | ||||
|                 // on définit l'ordre de l'élément en fonction du nombre de messages non lus | ||||
|                 // à l'aide d'une propriété CSS | ||||
|                 item.style.order = `${-channel.unread_messages}` | ||||
|             else | ||||
|                 // Sinon, les canaux sont de base triés par ordre alphabétique | ||||
|                 item.style.removeProperty('order') | ||||
|         } | ||||
|  | ||||
|         // On stocke le mode de tri dans le stockage local | ||||
|         localStorage.setItem('chat.sort-by-unread', sortByUnread) | ||||
|     }) | ||||
|  | ||||
|     // On récupère le mode de tri des canaux depuis le stockage local | ||||
|     if (localStorage.getItem('chat.sort-by-unread') === 'true') { | ||||
|         document.getElementById('sort-by-unread-switch').checked = true | ||||
|         document.getElementById('sort-by-unread-switch').dispatchEvent(new Event('change')) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Process the received data from the server. | ||||
|      * @param data The received message | ||||
|      * Des données sont reçues depuis le serveur. Elles sont traitées dans cette fonction, | ||||
|      * qui a pour but de trier et de répartir dans d'autres sous-fonctions. | ||||
|      * @param data Le message reçu. | ||||
|      */ | ||||
|     function processMessage(data) { | ||||
|         switch (data['type']) { | ||||
|         // On traite le message en fonction de son type | ||||
|         switch (data.type) { | ||||
|             case 'fetch_channels': | ||||
|                 setChannels(data['channels']) | ||||
|                 setChannels(data.channels) | ||||
|                 break | ||||
|             case 'send_message': | ||||
|                 receiveMessage(data) | ||||
| @@ -459,33 +713,43 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|                 startPrivateChat(data) | ||||
|                 break | ||||
|             default: | ||||
|                 // Le type de message est inconnu. On affiche une erreur dans la console. | ||||
|                 console.log(data) | ||||
|                 console.error('Unknown message type:', data['type']) | ||||
|                 console.error('Unknown message type:', data.type) | ||||
|                 break | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Configuration du socket de chat, permettant de communiquer avec le serveur. | ||||
|      * @param nextDelay Correspond au délai de reconnexion en cas d'erreur. | ||||
|      *                  Augmente exponentiellement en cas d'erreurs répétées, | ||||
|      *                  et se réinitialise à 1s en cas de connexion réussie. | ||||
|      */ | ||||
|     function setupSocket(nextDelay = 1000) { | ||||
|         // Open a global websocket | ||||
|         // Ouverture du socket | ||||
|         socket = new WebSocket( | ||||
|             (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/' | ||||
|         ) | ||||
|         let socketOpen = false | ||||
|  | ||||
|         // Listen on websockets and process messages from the server | ||||
|         // Écoute des messages reçus depuis le serveur | ||||
|         socket.addEventListener('message', e => { | ||||
|             // Parse received data as JSON | ||||
|             // Analyse du message reçu en tant que JSON | ||||
|             const data = JSON.parse(e.data) | ||||
|  | ||||
|             // Traite le message reçu | ||||
|             processMessage(data) | ||||
|         }) | ||||
|  | ||||
|         // Manage errors | ||||
|         // En cas d'erreur, on affiche un message et on réessaie de se connecter après un certain délai | ||||
|         // Ce délai double après chaque erreur répétée, jusqu'à un maximum de 2 minutes | ||||
|         socket.addEventListener('close', e => { | ||||
|             console.error('Chat socket closed unexpectedly, restarting…') | ||||
|             setTimeout(() => setupSocket(socketOpen ? 1000 : 2 * nextDelay), nextDelay) | ||||
|             setTimeout(() => setupSocket(Math.max(socketOpen ? 1000 : 2 * nextDelay, 120000)), nextDelay) | ||||
|         }) | ||||
|  | ||||
|         // En cas de connexion réussie, on demande au serveur les derniers messages pour chaque canal | ||||
|         socket.addEventListener('open', e => { | ||||
|             socketOpen = true | ||||
|             socket.send(JSON.stringify({ | ||||
| @@ -494,61 +758,101 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Configuration du swipe pour ouvrir et fermer le sélecteur de canaux. | ||||
|      * Fonctionne a priori uniquement sur les écrans tactiles. | ||||
|      * Lorsqu'on swipe de la gauche vers la droite, depuis le côté gauche de l'écran, on ouvre le sélecteur de canaux. | ||||
|      * Quand on swipe de la droite vers la gauche, on ferme le sélecteur de canaux. | ||||
|      */ | ||||
|     function setupSwipeOffscreen() { | ||||
|         // Récupération du sélecteur de canaux | ||||
|         const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector')) | ||||
|  | ||||
|         // L'écran a été touché. On récupère la coordonnée X de l'emplacement touché. | ||||
|         let lastX = null | ||||
|         document.addEventListener('touchstart', (event) => { | ||||
|             if (event.touches.length === 1) | ||||
|                 lastX = event.touches[0].clientX | ||||
|         }) | ||||
|  | ||||
|         // Le doigt a été déplacé. Selon le nouvel emplacement du doigt, on ouvre ou on ferme le sélecteur de canaux. | ||||
|         document.addEventListener('touchmove', (event) => { | ||||
|             if (event.touches.length === 1 && lastX !== null) { | ||||
|                 // L'écran a été touché à un seul doigt, et on a déjà récupéré la coordonnée X touchée. | ||||
|                 const diff = event.touches[0].clientX - lastX | ||||
|                 if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) { | ||||
|                     // Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la droite | ||||
|                     // et que le point de départ se trouve dans le quart gauche de l'écran, alors on ouvre le sélecteur | ||||
|                     offcanvas.show() | ||||
|                     lastX = null | ||||
|                 } | ||||
|                 else if (diff < -window.innerWidth / 10) { | ||||
|                     // Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la gauche, | ||||
|                     // alors on ferme le sélecteur | ||||
|                     offcanvas.hide() | ||||
|                     lastX = null | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|  | ||||
|         // Le doigt a été relâché. On réinitialise la coordonnée X touchée. | ||||
|         document.addEventListener('touchend', () => { | ||||
|             lastX = null | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Configuration du suivi de lecture des messages. | ||||
|      * Lorsque l'utilisateur⋅rice scrolle dans la fenêtre de chat, on vérifie quels sont les messages qui sont | ||||
|      * visibles à l'écran, et on les marque comme lus. | ||||
|      */ | ||||
|     function setupReadTracker() { | ||||
|         // Récupération du conteneur de messages | ||||
|         const scrollableContent = document.getElementById('chat-messages') | ||||
|         const messagesList = document.getElementById('message-list') | ||||
|         let markReadBuffer = [] | ||||
|         let markReadTimeout = null | ||||
|  | ||||
|         // Lorsqu'on scrolle, on récupère les anciens messages si on est tout en haut, | ||||
|         // et on marque les messages visibles comme lus | ||||
|         scrollableContent.addEventListener('scroll', () => { | ||||
|             if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight | ||||
|                 && !document.getElementById('fetch-previous-messages').classList.contains('d-none')) { | ||||
|                 // If the user is at the top of the chat, fetch previous messages | ||||
|                 // Si l'utilisateur⋅rice est en haut du chat, on récupère la liste des anciens messages | ||||
|                 fetchPreviousMessages()} | ||||
|  | ||||
|             // On marque les messages visibles comme lus | ||||
|             markVisibleMessagesAsRead() | ||||
|         }) | ||||
|  | ||||
|         // Lorsque les messages stockés sont mis à jour, on vérifie quels sont les messages visibles à marquer comme lus | ||||
|         messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead()) | ||||
|  | ||||
|         /** | ||||
|          * Marque les messages visibles à l'écran comme lus. | ||||
|          * On récupère pour cela les coordonnées du conteneur de messages ainsi que les coordonnées de chaque message | ||||
|          * et on vérifie si le message est visible à l'écran. Si c'est le cas, on le marque comme lu. | ||||
|          * Après 3 secondes d'attente après qu'aucun message n'ait été lu, | ||||
|          * on envoie la liste des messages lus au serveur. | ||||
|          */ | ||||
|         function markVisibleMessagesAsRead() { | ||||
|             // Récupération des coordonnées visibles du conteneur de messages | ||||
|             let viewport = scrollableContent.getBoundingClientRect() | ||||
|  | ||||
|             for (let item of messagesList.querySelectorAll('.message')) { | ||||
|                 let message = messages[selected_channel_id].get(parseInt(item.getAttribute('data-message-id'))) | ||||
|                 if (!message.read) { | ||||
|                     // Si le message n'a pas déjà été lu, on récupère ses coordonnées | ||||
|                     let rect = item.getBoundingClientRect() | ||||
|                     if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) { | ||||
|                         // Si les coordonnées sont entièrement incluses dans le rectangle visible, on le marque comme lu | ||||
|                         // et comme étant à envoyer au serveur | ||||
|                         message.read = true | ||||
|                         markReadBuffer.push(message['id']) | ||||
|                         markReadBuffer.push(message.id) | ||||
|                         if (markReadTimeout) | ||||
|                             clearTimeout(markReadTimeout) | ||||
|                         // 3 secondes après qu'aucun nouveau message n'ait été rajouté, on envoie la liste des messages | ||||
|                         // lus au serveur | ||||
|                         markReadTimeout = setTimeout(() => { | ||||
|                             socket.send(JSON.stringify({ | ||||
|                                 'type': 'mark_read', | ||||
| @@ -562,23 +866,36 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // On considère les messages d'ores-et-déjà visibles comme lus | ||||
|         markVisibleMessagesAsRead() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Configuration de la demande d'installation de l'application en tant qu'application web progressive (PWA). | ||||
|      * Lorsque l'utilisateur⋅rice arrive sur la page, on lui propose de télécharger l'application | ||||
|      * pour l'ajouter à son écran d'accueil. | ||||
|      * Fonctionne uniquement sur les navigateurs compatibles. | ||||
|      */ | ||||
|     function setupPWAPrompt() { | ||||
|         let deferredPrompt = null | ||||
|  | ||||
|         window.addEventListener("beforeinstallprompt", (e) => { | ||||
|             // Une demande d'installation a été faite. On commence par empêcher l'action par défaut. | ||||
|             e.preventDefault() | ||||
|             deferredPrompt = e | ||||
|  | ||||
|             // L'installation est possible, on rend visible le bouton de téléchargement | ||||
|             // ainsi que le message qui indique c'est possible. | ||||
|             let btn = document.getElementById('install-app-home-screen') | ||||
|             let alert = document.getElementById('alert-download-chat-app') | ||||
|             btn.classList.remove('d-none') | ||||
|             alert.classList.remove('d-none') | ||||
|             btn.onclick = function () { | ||||
|                 // Lorsque le bouton de téléchargement est cliqué, on lance l'installation du PWA. | ||||
|                 deferredPrompt.prompt() | ||||
|                 deferredPrompt.userChoice.then((choiceResult) => { | ||||
|                     if (choiceResult.outcome === 'accepted') { | ||||
|                         // Si l'installation a été acceptée, on masque le bouton de téléchargement. | ||||
|                         deferredPrompt = null | ||||
|                         btn.classList.add('d-none') | ||||
|                         alert.classList.add('d-none') | ||||
| @@ -588,8 +905,8 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     setupSocket() | ||||
|     setupSwipeOffscreen() | ||||
|     setupReadTracker() | ||||
|     setupPWAPrompt() | ||||
|     setupSocket()  // Configuration du Websocket | ||||
|     setupSwipeOffscreen()  // Configuration du swipe sur les écrans tactiles pour le sélecteur de canaux | ||||
|     setupReadTracker()  // Configuration du suivi de lecture des messages | ||||
|     setupPWAPrompt()  // Configuration de l'installateur d'application en tant qu'application web progressive | ||||
| }) | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block extracss %} | ||||
|     {# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #} | ||||
|     <link rel="manifest" href="{% static "chat.webmanifest" %}"> | ||||
| {% endblock %} | ||||
|  | ||||
| @@ -14,6 +15,6 @@ | ||||
| {% endblock %} | ||||
|  | ||||
| {% block extrajavascript %} | ||||
|     {# This script contains all data for the chat management #} | ||||
|     {# Ce script contient toutes les données pour la gestion du chat. #} | ||||
|     <script src="{% static 'chat.js' %}"></script> | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -1,32 +1,40 @@ | ||||
| {% load i18n %} | ||||
|  | ||||
| <noscript> | ||||
|     {# Le chat fonctionne à l'aide d'un script JavaScript, sans JavaScript activé il n'est pas possible d'utiliser le chat. #} | ||||
|     {% trans "JavaScript must be enabled on your browser to access chat." %} | ||||
| </noscript> | ||||
| <div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle"> | ||||
|     <div class="offcanvas-header"> | ||||
|         {# Titre du sélecteur de canaux #} | ||||
|         <h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3> | ||||
|         <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button> | ||||
|     </div> | ||||
|     <div class="offcanvas-body"> | ||||
|         {# Contenu du sélecteur de canaux #} | ||||
|         <div class="form-switch form-switch"> | ||||
|             <input class="form-check-input" type="checkbox" role="switch" id="sort-by-unread-switch"> | ||||
|             <label class="form-check-label" for="sort-by-unread-switch">{% trans "Sort by unread messages" %}</label> | ||||
|         </div> | ||||
|         <ul class="list-group list-group-flush" id="nav-channels-tab"> | ||||
|             {# Liste des différentes catégories, avec les canaux par catégorie #} | ||||
|             <li class="list-group-item d-none"> | ||||
|                 {# Canaux généraux #} | ||||
|                 <h4>{% trans "General channels" %}</h4> | ||||
|                 <ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul> | ||||
|             </li> | ||||
|             <li class="list-group-item d-none"> | ||||
|                 {# Canaux liés à un tournoi #} | ||||
|                 <h4>{% trans "Tournament channels" %}</h4> | ||||
|                 <ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul> | ||||
|             </li> | ||||
|             <li class="list-group-item d-none"> | ||||
|                 {# Canaux d'équipes #} | ||||
|                 <h4>{% trans "Team channels" %}</h4> | ||||
|                 <ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul> | ||||
|             </li> | ||||
|             <li class="list-group-item d-none"> | ||||
|                 {# Échanges privés #} | ||||
|                 <h4>{% trans "Private channels" %}</h4> | ||||
|                 <ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul> | ||||
|             </li> | ||||
| @@ -35,32 +43,41 @@ | ||||
| </div> | ||||
|  | ||||
| <div class="alert alert-info d-none" id="alert-download-chat-app"> | ||||
|     {# Lorsque l'application du chat est installable (par exemple sur un Chrome sur Android), on affiche le message qui indique que c'est bien possible. #} | ||||
|     {% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %} | ||||
| </div> | ||||
|  | ||||
| {# Conteneur principal du chat. #} | ||||
| {# Lorsque le chat est en plein écran, on le place en coordonnées absolues, occupant tout l'espace de l'écran. #} | ||||
| <div class="card tab-content w-100 mh-100{% if request.GET.fullscreen == '1' or fullscreen %} position-absolute top-0 start-0 vh-100 z-3{% endif %}" | ||||
|      style="height: 95vh" id="chat-container"> | ||||
|     <div class="card-header"> | ||||
|         <h3> | ||||
|         {% if fullscreen %} | ||||
|             {# Logout button must be present in a form. The form must includes the whole line. #} | ||||
|             {# Lorsque le chat est en plein écran, on affiche le bouton de déconnexion. #} | ||||
|             {# Le bouton de déconnexion doit être présent dans un formulaire. Le formulaire doit inclure toute la ligne. #} | ||||
|             <form action="{% url 'chat:logout' %}" method="post"> | ||||
|                 {% csrf_token %} | ||||
|         {% endif %} | ||||
|             {# Bouton qui permet d'ouvrir le sélecteur de canaux #} | ||||
|             <button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#channelSelector" | ||||
|                     aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector"> | ||||
|                 <span class="navbar-toggler-icon"></span> | ||||
|             </button> | ||||
|             <span id="channel-title"></span> | ||||
|  | ||||
|             <span id="channel-title"></span> {# Titre du canal sélectionné #} | ||||
|             {% if not fullscreen %} | ||||
|                 {# Dans le cas où on est pas uniquement en plein écran (cas de l'application), on affiche les boutons pour passer en ou quitter le mode plein écran. #} | ||||
|                 <button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}"> | ||||
|                     <i class="fas fa-expand"></i> | ||||
|                 </button> | ||||
|             {% else %} | ||||
|                 {# Le bouton de déconnexion n'est affiché que sur l'application. #} | ||||
|                 <button class="btn float-end" title="{% trans "Log out" %}"> | ||||
|                     <i class="fas fa-sign-out-alt"></i> | ||||
|                 </button> | ||||
|             {% endif %} | ||||
|             {# On affiche le bouton d'installation uniquement dans le cas où l'application est installable sur l'écran d'accueil. #} | ||||
|             <button class="btn float-end d-none" type="button" id="install-app-home-screen" title="{% trans "Install app on home screen" %}"> | ||||
|                 <i class="fas fa-download"></i> | ||||
|             </button> | ||||
| @@ -69,8 +86,12 @@ | ||||
|             {% endif %} | ||||
|         </h3> | ||||
|     </div> | ||||
|  | ||||
|     {# Contenu de la carte, contenant la liste des messages. La liste des messages est affichée à l'envers pour avoir un scroll plus cohérent. #} | ||||
|     <div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages"> | ||||
|         {# Correspond à la liste des messages à afficher. #} | ||||
|         <ul class="list-group list-group-flush" id="message-list"></ul> | ||||
|         {# S'il y a des messages à récupérer, on affiche un lien qui permet de récupérer les anciens messages. #} | ||||
|         <div class="text-center d-none" id="fetch-previous-messages"> | ||||
|             <a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()"> | ||||
|                 {% trans "Fetch previous messages…" %} | ||||
| @@ -78,12 +99,16 @@ | ||||
|             <hr> | ||||
|         </div> | ||||
|     </div> | ||||
|      | ||||
|     {# Pied de la carte, contenant le formulaire pour envoyer un message. #} | ||||
|     <div class="card-footer mt-auto"> | ||||
|         {# Lorsqu'on souhaite envoyer un message, on empêche le formulaire de s'envoyer et on envoie le message par websocket. #} | ||||
|         <form onsubmit="event.preventDefault(); sendMessage()"> | ||||
|             <div class="input-group"> | ||||
|                 <label for="input-message" class="input-group-text"> | ||||
|                     <i class="fas fa-comment"></i> | ||||
|                 </label> | ||||
|                 {# Affichage du contrôleur de texte pour rédiger le message à envoyer. #} | ||||
|                 <input type="text" class="form-control" id="input-message" placeholder="{% trans "Send message…" %}" autofocus autocomplete="off"> | ||||
|                 <button class="input-group-text btn btn-success" type="submit"> | ||||
|                     <i class="fas fa-paper-plane"></i> | ||||
| @@ -94,6 +119,8 @@ | ||||
| </div> | ||||
|  | ||||
| <script> | ||||
|     {# Récupération de l'utilisateur⋅rice courant⋅e afin de pouvoir effectuer des tests plus tard. #} | ||||
|     const USER_ID = {{ request.user.id }} | ||||
|     {# Récupération du statut administrateur⋅rice de l'utilisateur⋅rice connecté⋅e afin de pouvoir effectuer des tests plus tard. #} | ||||
|     const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }} | ||||
| </script> | ||||
| @@ -7,9 +7,9 @@ | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||
|     <title> | ||||
|         Chat du TFJM² | ||||
|         {% trans "TFJM² Chat" %} | ||||
|     </title> | ||||
|     <meta name="description" content="Chat du TFJM²"> | ||||
|     <meta name="description" content="{% trans "TFJM² Chat" %}"> | ||||
|  | ||||
|     {# Favicon #} | ||||
|     <link rel="shortcut icon" href="{% static "favicon.ico" %}"> | ||||
| @@ -23,12 +23,15 @@ | ||||
|     {# Bootstrap JavaScript #} | ||||
|     <script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script> | ||||
|  | ||||
|     {# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #} | ||||
|     <link rel="manifest" href="{% static "chat.webmanifest" %}"> | ||||
| </head> | ||||
| <body class="d-flex w-100 h-100 flex-column"> | ||||
| {% include "chat/content.html" with fullscreen=True %} | ||||
|  | ||||
| {# Inclusion du script permettant de gérer le thème sombre et le thème clair #} | ||||
| <script src="{% static 'theme.js' %}"></script> | ||||
| {# Inclusion du script gérant le chat #} | ||||
| <script src="{% static 'chat.js' %}"></script> | ||||
| </body> | ||||
| </html> | ||||
|   | ||||
| @@ -7,9 +7,9 @@ | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||
|     <title> | ||||
|         Chat du TFJM² - {% trans "Log in" %} | ||||
|         {% trans "TFJM² Chat" %} - {% trans "Log in" %} | ||||
|     </title> | ||||
|     <meta name="description" content="Chat du TFJM²"> | ||||
|     <meta name="description" content="{% trans "TFJM² Chat" %}"> | ||||
|  | ||||
|     {# Favicon #} | ||||
|     <link rel="shortcut icon" href="{% static "favicon.ico" %}"> | ||||
| @@ -23,6 +23,7 @@ | ||||
|     {# Bootstrap JavaScript #} | ||||
|     <script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script> | ||||
|  | ||||
|     {# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #} | ||||
|     <link rel="manifest" href="{% static "chat.webmanifest" %}"> | ||||
| </head> | ||||
| <body class="d-flex w-100 h-100 flex-column"> | ||||
| @@ -31,6 +32,7 @@ | ||||
|         {% include "registration/includes/login.html" %} | ||||
|     </div> | ||||
|  | ||||
| {# Inclusion du script permettant de gérer le thème sombre et le thème clair #} | ||||
| <script src="{% static 'theme.js' %}"></script> | ||||
| </body> | ||||
| </html> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user