Compare commits
No commits in common. "5e2add90a89b62fd86be619e8a7a8f406f562a2f" and "8216e0943f7015fb6c8becc94fdea00bab69a8cc" have entirely different histories.
5e2add90a8
...
8216e0943f
@ -3,12 +3,10 @@ FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
|
||||
|
||||
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev npm postgresql-dev libmagic texlive texmf-dist-latexextra
|
||||
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive texmf-dist-latexextra
|
||||
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
RUN npm install -g yuglify
|
||||
|
||||
RUN mkdir /code /code/docs
|
||||
WORKDIR /code
|
||||
COPY requirements.txt /code/requirements.txt
|
||||
|
@ -8,9 +8,6 @@ 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',)
|
||||
@ -19,9 +16,6 @@ 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,101 +11,76 @@ from .models import Channel, Message
|
||||
|
||||
class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
"""
|
||||
Ce consommateur gère les connexions WebSocket pour le chat.
|
||||
This consumer manages the websocket of the chat interface.
|
||||
"""
|
||||
async def connect(self) -> None:
|
||||
"""
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
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'])
|
||||
|
||||
# Récupération de l'utilisateur⋅rice courant⋅e
|
||||
# Fetch the registration of the current user
|
||||
user = self.scope['user']
|
||||
if user.is_anonymous:
|
||||
# L'utilisateur⋅rice n'est pas connecté⋅e
|
||||
# User is not authenticated
|
||||
await self.close()
|
||||
return
|
||||
|
||||
reg = await Registration.objects.aget(user_id=user.id)
|
||||
self.registration = reg
|
||||
|
||||
# Acceptation de la connexion
|
||||
# Accept the connection
|
||||
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: int) -> None:
|
||||
async def disconnect(self, close_code) -> None:
|
||||
"""
|
||||
Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur.
|
||||
:param close_code: Le code d'erreur.
|
||||
Called when the websocket got disconnected, for any reason.
|
||||
:param close_code: The error code.
|
||||
"""
|
||||
if self.scope['user'].is_anonymous:
|
||||
# L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien
|
||||
# User is not authenticated
|
||||
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: dict, **kwargs) -> None:
|
||||
async def receive_json(self, content, **kwargs):
|
||||
"""
|
||||
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'.
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
# Type inconnu, on soulève une erreur
|
||||
raise ValueError(f"Unknown message type: {unknown}")
|
||||
print("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': [
|
||||
@ -117,37 +92,27 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
'write_access': await self.write_channels.acontains(channel),
|
||||
'unread_messages': channel.unread_messages,
|
||||
}
|
||||
async for channel in channels
|
||||
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()
|
||||
]
|
||||
}
|
||||
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,
|
||||
@ -159,27 +124,14 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
})
|
||||
|
||||
async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
|
||||
"""
|
||||
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)
|
||||
user = self.scope['user']
|
||||
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,
|
||||
@ -188,24 +140,13 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
})
|
||||
|
||||
async def delete_message(self, message_id: int, **kwargs) -> None:
|
||||
"""
|
||||
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)
|
||||
user = self.scope['user']
|
||||
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,
|
||||
@ -213,30 +154,16 @@ 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) # On limite le nombre de messages à 200 maximum
|
||||
limit = min(limit, 200) # Fetch only maximum 200 messages at the time
|
||||
|
||||
# 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,
|
||||
@ -254,22 +181,13 @@ 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()],
|
||||
@ -278,21 +196,10 @@ 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,
|
||||
@ -300,11 +207,9 @@ 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': {
|
||||
@ -316,10 +221,8 @@ 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': {
|
||||
@ -332,39 +235,17 @@ 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,18 +10,9 @@ 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(
|
||||
@ -31,7 +22,6 @@ 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(
|
||||
@ -41,7 +31,6 @@ 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(
|
||||
@ -51,8 +40,6 @@ 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(
|
||||
@ -62,7 +49,6 @@ class Command(BaseCommand):
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal de discussion libre est accessible pour tous⋅tes.
|
||||
Channel.objects.update_or_create(
|
||||
name="Détente",
|
||||
defaults=dict(
|
||||
@ -73,10 +59,6 @@ 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(
|
||||
@ -107,7 +89,6 @@ 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(
|
||||
@ -119,7 +100,6 @@ 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(
|
||||
@ -131,8 +111,6 @@ 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(
|
||||
@ -154,7 +132,6 @@ 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(
|
||||
|
@ -1,94 +0,0 @@
|
||||
# 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,11 +13,6 @@ 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")
|
||||
@ -27,7 +22,6 @@ class Channel(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("name"),
|
||||
help_text=_("Visible name of the channel."),
|
||||
)
|
||||
|
||||
category = models.CharField(
|
||||
@ -35,22 +29,18 @@ 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(
|
||||
@ -102,20 +92,10 @@ 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):
|
||||
@ -123,77 +103,39 @@ 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()),
|
||||
@ -209,20 +151,15 @@ 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
|
||||
@ -234,12 +171,6 @@ 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,
|
||||
@ -277,74 +208,55 @@ class Message(models.Model):
|
||||
help_text=_("Users who have read the message."),
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
def get_author_name(self):
|
||||
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:
|
||||
@ -352,11 +264,7 @@ class Message(models.Model):
|
||||
|
||||
return author_name
|
||||
|
||||
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.
|
||||
"""
|
||||
async def aget_author_name(self):
|
||||
return await sync_to_async(self.get_author_name)()
|
||||
|
||||
class Meta:
|
||||
|
@ -7,14 +7,8 @@ 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(
|
||||
@ -25,7 +19,6 @@ 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(
|
||||
@ -36,7 +29,6 @@ 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(
|
||||
@ -47,7 +39,6 @@ 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(
|
||||
@ -59,7 +50,6 @@ 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(
|
||||
@ -72,17 +62,10 @@ 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(
|
||||
@ -105,9 +88,6 @@ 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}",
|
||||
|
595
chat/static/chat.js
Normal file
@ -0,0 +1,595 @@
|
||||
(async () => {
|
||||
// check notification permission
|
||||
// This is useful to alert people that they should do something
|
||||
await Notification.requestPermission()
|
||||
})()
|
||||
|
||||
const MAX_MESSAGES = 50
|
||||
|
||||
const channel_categories = ['general', 'tournament', 'team', 'private']
|
||||
let channels = {}
|
||||
let messages = {}
|
||||
let selected_channel_id = null
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @return Notification
|
||||
*/
|
||||
function showNotification(title, body, timeout = 5000) {
|
||||
Notification.requestPermission().then((status) => {
|
||||
if (status === 'granted')
|
||||
new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"})
|
||||
})
|
||||
}
|
||||
|
||||
function selectChannel(channel_id) {
|
||||
let channel = channels[channel_id]
|
||||
if (!channel) {
|
||||
console.error('Channel not found:', channel_id)
|
||||
return
|
||||
}
|
||||
|
||||
selected_channel_id = channel_id
|
||||
localStorage.setItem('chat.last-channel-id', channel_id)
|
||||
|
||||
let channelTitle = document.getElementById('channel-title')
|
||||
channelTitle.innerText = channel['name']
|
||||
|
||||
let messageInput = document.getElementById('input-message')
|
||||
messageInput.disabled = !channel['write_access']
|
||||
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
let messageInput = document.getElementById('input-message')
|
||||
let message = messageInput.value
|
||||
messageInput.value = ''
|
||||
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'send_message',
|
||||
'channel_id': selected_channel_id,
|
||||
'content': message,
|
||||
}))
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
for (let channel of new_channels)
|
||||
addChannel(channel, categoryLists)
|
||||
|
||||
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
|
||||
let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id'))
|
||||
if (last_channel_id && channels[last_channel_id])
|
||||
selectChannel(last_channel_id)
|
||||
else
|
||||
selectChannel(Object.keys(channels)[0])
|
||||
}
|
||||
}
|
||||
|
||||
async function addChannel(channel, categoryLists) {
|
||||
channels[channel['id']] = channel
|
||||
if (!messages[channel['id']])
|
||||
messages[channel['id']] = new Map()
|
||||
|
||||
let categoryList = categoryLists[channel['category']]
|
||||
categoryList.parentElement.classList.remove('d-none')
|
||||
|
||||
let navItem = document.createElement('li')
|
||||
navItem.classList.add('list-group-item', 'tab-channel')
|
||||
navItem.id = `tab-channel-${channel['id']}`
|
||||
navItem.setAttribute('data-bs-dismiss', 'offcanvas')
|
||||
navItem.onclick = () => selectChannel(channel['id'])
|
||||
categoryList.appendChild(navItem)
|
||||
|
||||
let channelButton = document.createElement('button')
|
||||
channelButton.classList.add('nav-link')
|
||||
channelButton.type = 'button'
|
||||
channelButton.innerText = channel['name']
|
||||
navItem.appendChild(channelButton)
|
||||
|
||||
let unreadBadge = document.createElement('span')
|
||||
unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2')
|
||||
unreadBadge.id = `unread-messages-${channel['id']}`
|
||||
unreadBadge.innerText = channel.unread_messages || 0
|
||||
if (!channel.unread_messages)
|
||||
unreadBadge.classList.add('d-none')
|
||||
channelButton.appendChild(unreadBadge)
|
||||
|
||||
if (document.getElementById('sort-by-unread-switch').checked)
|
||||
navItem.style.order = `${-channel.unread_messages}`
|
||||
|
||||
fetchMessages(channel['id'])
|
||||
}
|
||||
|
||||
function receiveMessage(message) {
|
||||
let scrollableContent = document.getElementById('chat-messages')
|
||||
let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1
|
||||
|
||||
messages[message['channel_id']].set(message['id'], message)
|
||||
redrawMessages()
|
||||
|
||||
// Scroll to bottom if the user was already at the bottom
|
||||
if (isScrolledToBottom)
|
||||
scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight
|
||||
|
||||
if (message['content'].includes("@everyone"))
|
||||
showNotification(channels[message['channel_id']]['name'], `${message['author']} : ${message['content']}`)
|
||||
}
|
||||
|
||||
function editMessage(data) {
|
||||
messages[data['channel_id']].get(data['id'])['content'] = data['content']
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
function deleteMessage(data) {
|
||||
messages[data['channel_id']].delete(data['id'])
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'fetch_messages',
|
||||
'channel_id': channel_id,
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
}))
|
||||
}
|
||||
|
||||
function fetchPreviousMessages() {
|
||||
let channel_id = selected_channel_id
|
||||
let offset = messages[channel_id].size
|
||||
fetchMessages(channel_id, offset, MAX_MESSAGES)
|
||||
}
|
||||
|
||||
function receiveFetchedMessages(data) {
|
||||
let channel_id = data['channel_id']
|
||||
let new_messages = data['messages']
|
||||
|
||||
if (!messages[channel_id])
|
||||
messages[channel_id] = new Map()
|
||||
|
||||
for (let message of new_messages)
|
||||
messages[channel_id].set(message['id'], message)
|
||||
|
||||
// Sort messages by timestamp
|
||||
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]))
|
||||
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
function markMessageAsRead(data) {
|
||||
for (let message of data['messages']) {
|
||||
let stored_message = messages[message['channel_id']].get(message['id'])
|
||||
if (stored_message)
|
||||
stored_message['read'] = true
|
||||
}
|
||||
redrawMessages()
|
||||
updateUnreadBadges(data['unread_messages'])
|
||||
}
|
||||
|
||||
function updateUnreadBadges(unreadMessages) {
|
||||
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
|
||||
|
||||
let unreadBadge = document.getElementById(`unread-messages-${channel['id']}`)
|
||||
unreadBadge.innerText = unreadMessagesChannel
|
||||
if (unreadMessagesChannel)
|
||||
unreadBadge.classList.remove('d-none')
|
||||
else
|
||||
unreadBadge.classList.add('d-none')
|
||||
|
||||
if (sortByUnread)
|
||||
document.getElementById(`tab-channel-${channel['id']}`).style.order = `${-unreadMessagesChannel}`
|
||||
}
|
||||
}
|
||||
|
||||
function startPrivateChat(data) {
|
||||
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))
|
||||
}
|
||||
|
||||
selectChannel(channel['id'])
|
||||
}
|
||||
|
||||
function redrawMessages() {
|
||||
let messageList = document.getElementById('message-list')
|
||||
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 ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) {
|
||||
let messageContentDiv = document.createElement('div')
|
||||
messageContentDiv.classList.add('message')
|
||||
messageContentDiv.setAttribute('data-message-id', message['id'])
|
||||
lastContentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
messageContentSpan.innerHTML = markdownToHTML(message['content'])
|
||||
messageContentDiv.appendChild(messageContentSpan)
|
||||
|
||||
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let messageElement = document.createElement('li')
|
||||
messageElement.classList.add('list-group-item')
|
||||
messageList.appendChild(messageElement)
|
||||
|
||||
let authorDiv = document.createElement('div')
|
||||
messageElement.appendChild(authorDiv)
|
||||
|
||||
let authorSpan = document.createElement('span')
|
||||
authorSpan.classList.add('text-muted', 'fw-bold')
|
||||
authorSpan.innerText = message['author']
|
||||
authorDiv.appendChild(authorSpan)
|
||||
|
||||
registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan)
|
||||
|
||||
let dateSpan = document.createElement('span')
|
||||
dateSpan.classList.add('text-muted', 'float-end')
|
||||
dateSpan.innerText = new Date(message['timestamp']).toLocaleString()
|
||||
authorDiv.appendChild(dateSpan)
|
||||
|
||||
let contentDiv = document.createElement('div')
|
||||
messageElement.appendChild(contentDiv)
|
||||
|
||||
let messageContentDiv = document.createElement('div')
|
||||
messageContentDiv.classList.add('message')
|
||||
messageContentDiv.setAttribute('data-message-id', message['id'])
|
||||
contentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
messageContentSpan.innerHTML = markdownToHTML(message['content'])
|
||||
messageContentDiv.appendChild(messageContentSpan)
|
||||
|
||||
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
||||
|
||||
lastMessage = message
|
||||
lastContentDiv = contentDiv
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
messageList.dispatchEvent(new CustomEvent('updatemessages'))
|
||||
}
|
||||
|
||||
function markdownToHTML(text) {
|
||||
let safeText = text.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
let lines = safeText.split('\n')
|
||||
let htmlLines = []
|
||||
for (let line of lines) {
|
||||
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, '<pre>$1</pre>') // Code
|
||||
.replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>') // Links
|
||||
htmlLines.push(htmlLine)
|
||||
}
|
||||
return htmlLines.join('<br>')
|
||||
}
|
||||
|
||||
function removeAllPopovers() {
|
||||
for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) {
|
||||
let instance = bootstrap.Popover.getInstance(popover)
|
||||
if (instance)
|
||||
instance.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function registerSendPrivateMessageContextMenu(message, div, span) {
|
||||
div.addEventListener('contextmenu', (menu_event) => {
|
||||
menu_event.preventDefault()
|
||||
removeAllPopovers()
|
||||
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>`,
|
||||
'html': true,
|
||||
})
|
||||
popover.show()
|
||||
|
||||
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'],
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function registerMessageContextMenu(message, div, span) {
|
||||
div.addEventListener('contextmenu', (menu_event) => {
|
||||
menu_event.preventDefault()
|
||||
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
|
||||
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>`
|
||||
}
|
||||
|
||||
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
||||
'content': content,
|
||||
'html': true,
|
||||
'placement': 'bottom',
|
||||
})
|
||||
popover.show()
|
||||
|
||||
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'],
|
||||
}))
|
||||
})
|
||||
|
||||
if (has_right_to_edit) {
|
||||
document.getElementById('edit-message-' + message['id']).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
popover.dispose()
|
||||
let new_message = prompt("Modifier le message", message['content'])
|
||||
if (new_message) {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'edit_message',
|
||||
'message_id': message['id'],
|
||||
'content': new_message,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('delete-message-' + message['id']).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
popover.dispose()
|
||||
if (confirm(`Supprimer le message ?\n${message['content']}`)) {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'delete_message',
|
||||
'message_id': message['id'],
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
let chatContainer = document.getElementById('chat-container')
|
||||
if (!chatContainer.getAttribute('data-fullscreen')) {
|
||||
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 {
|
||||
chatContainer.removeAttribute('data-fullscreen')
|
||||
chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
||||
window.history.replaceState({}, null, `?fullscreen=0`)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('click', removeAllPopovers)
|
||||
|
||||
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']}`)
|
||||
if (sortByUnread)
|
||||
item.style.order = `${-channel.unread_messages}`
|
||||
else
|
||||
item.style.removeProperty('order')
|
||||
}
|
||||
|
||||
localStorage.setItem('chat.sort-by-unread', sortByUnread)
|
||||
})
|
||||
|
||||
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
|
||||
*/
|
||||
function processMessage(data) {
|
||||
switch (data['type']) {
|
||||
case 'fetch_channels':
|
||||
setChannels(data['channels'])
|
||||
break
|
||||
case 'send_message':
|
||||
receiveMessage(data)
|
||||
break
|
||||
case 'edit_message':
|
||||
editMessage(data)
|
||||
break
|
||||
case 'delete_message':
|
||||
deleteMessage(data)
|
||||
break
|
||||
case 'fetch_messages':
|
||||
receiveFetchedMessages(data)
|
||||
break
|
||||
case 'mark_read':
|
||||
markMessageAsRead(data)
|
||||
break
|
||||
case 'start_private_chat':
|
||||
startPrivateChat(data)
|
||||
break
|
||||
default:
|
||||
console.log(data)
|
||||
console.error('Unknown message type:', data['type'])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function setupSocket(nextDelay = 1000) {
|
||||
// Open a global websocket
|
||||
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
|
||||
socket.addEventListener('message', e => {
|
||||
// Parse received data as JSON
|
||||
const data = JSON.parse(e.data)
|
||||
|
||||
processMessage(data)
|
||||
})
|
||||
|
||||
// Manage errors
|
||||
socket.addEventListener('close', e => {
|
||||
console.error('Chat socket closed unexpectedly, restarting…')
|
||||
setTimeout(() => setupSocket(socketOpen ? 1000 : 2 * nextDelay), nextDelay)
|
||||
})
|
||||
|
||||
socket.addEventListener('open', e => {
|
||||
socketOpen = true
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'fetch_channels',
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function setupSwipeOffscreen() {
|
||||
const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector'))
|
||||
|
||||
let lastX = null
|
||||
document.addEventListener('touchstart', (event) => {
|
||||
if (event.touches.length === 1)
|
||||
lastX = event.touches[0].clientX
|
||||
})
|
||||
document.addEventListener('touchmove', (event) => {
|
||||
if (event.touches.length === 1 && lastX !== null) {
|
||||
const diff = event.touches[0].clientX - lastX
|
||||
if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) {
|
||||
offcanvas.show()
|
||||
lastX = null
|
||||
}
|
||||
else if (diff < -window.innerWidth / 10) {
|
||||
offcanvas.hide()
|
||||
lastX = null
|
||||
}
|
||||
}
|
||||
})
|
||||
document.addEventListener('touchend', () => {
|
||||
lastX = null
|
||||
})
|
||||
}
|
||||
|
||||
function setupReadTracker() {
|
||||
const scrollableContent = document.getElementById('chat-messages')
|
||||
const messagesList = document.getElementById('message-list')
|
||||
let markReadBuffer = []
|
||||
let markReadTimeout = null
|
||||
|
||||
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
|
||||
fetchPreviousMessages()}
|
||||
|
||||
markVisibleMessagesAsRead()
|
||||
})
|
||||
|
||||
messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead())
|
||||
|
||||
function markVisibleMessagesAsRead() {
|
||||
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) {
|
||||
let rect = item.getBoundingClientRect()
|
||||
if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) {
|
||||
message.read = true
|
||||
markReadBuffer.push(message['id'])
|
||||
if (markReadTimeout)
|
||||
clearTimeout(markReadTimeout)
|
||||
markReadTimeout = setTimeout(() => {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'mark_read',
|
||||
'message_ids': markReadBuffer,
|
||||
}))
|
||||
markReadBuffer = []
|
||||
markReadTimeout = null
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markVisibleMessagesAsRead()
|
||||
}
|
||||
|
||||
function setupPWAPrompt() {
|
||||
let deferredPrompt = null
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt = e
|
||||
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 () {
|
||||
deferredPrompt.prompt()
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
deferredPrompt = null
|
||||
btn.classList.add('d-none')
|
||||
alert.classList.add('d-none')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setupSocket()
|
||||
setupSwipeOffscreen()
|
||||
setupReadTracker()
|
||||
setupPWAPrompt()
|
||||
})
|
@ -4,19 +4,19 @@
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-square.svg",
|
||||
"src": "tfjm-square.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-512.png",
|
||||
"src": "tfjm-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-192.png",
|
||||
"src": "tfjm-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
@ -1,912 +0,0 @@
|
||||
(async () => {
|
||||
// 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 // Nombre maximal de messages à charger à la fois
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 = 0) {
|
||||
Notification.requestPermission().then((status) => {
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
// 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,
|
||||
'content': message,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {}
|
||||
for (let category of channel_categories) {
|
||||
// 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)
|
||||
// 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)
|
||||
else
|
||||
selectChannel(Object.keys(channels)[0])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
// 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.setAttribute('data-bs-dismiss', 'offcanvas')
|
||||
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
|
||||
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.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}`
|
||||
|
||||
// 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
|
||||
|
||||
// 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()
|
||||
|
||||
// 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
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
// 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,
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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)
|
||||
|
||||
// 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]))
|
||||
|
||||
// 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) {
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
|
||||
// Récupération du canal concerné
|
||||
let channel = channels[channel_id]
|
||||
|
||||
// Récupération du nombre de messages non lus pour le canal en question, que l'on stocke
|
||||
channel.unread_messages = unreadMessagesCount
|
||||
|
||||
// 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) {
|
||||
// Récupération du canal
|
||||
let channel = data.channel
|
||||
if (!channel) {
|
||||
console.error('Private chat not found:', data)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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)
|
||||
lastContentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
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
|
||||
authorDiv.appendChild(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()
|
||||
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)
|
||||
contentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
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, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
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>') // 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>') // 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)
|
||||
if (instance)
|
||||
instance.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>`,
|
||||
'html': true,
|
||||
})
|
||||
popover.show()
|
||||
|
||||
// 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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
// 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>`
|
||||
}
|
||||
|
||||
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
||||
'content': content,
|
||||
'html': true,
|
||||
'placement': 'bottom',
|
||||
})
|
||||
popover.show()
|
||||
|
||||
// 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,
|
||||
}))
|
||||
})
|
||||
|
||||
if (has_right_to_edit) {
|
||||
// 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()
|
||||
|
||||
// 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,
|
||||
'content': new_message,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
||||
// 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,
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`)
|
||||
}
|
||||
}
|
||||
|
||||
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}`)
|
||||
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'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// On traite le message en fonction de son type
|
||||
switch (data.type) {
|
||||
case 'fetch_channels':
|
||||
setChannels(data.channels)
|
||||
break
|
||||
case 'send_message':
|
||||
receiveMessage(data)
|
||||
break
|
||||
case 'edit_message':
|
||||
editMessage(data)
|
||||
break
|
||||
case 'delete_message':
|
||||
deleteMessage(data)
|
||||
break
|
||||
case 'fetch_messages':
|
||||
receiveFetchedMessages(data)
|
||||
break
|
||||
case 'mark_read':
|
||||
markMessageAsRead(data)
|
||||
break
|
||||
case 'start_private_chat':
|
||||
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)
|
||||
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) {
|
||||
// Ouverture du socket
|
||||
socket = new WebSocket(
|
||||
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/'
|
||||
)
|
||||
let socketOpen = false
|
||||
|
||||
// Écoute des messages reçus depuis le serveur
|
||||
socket.addEventListener('message', e => {
|
||||
// Analyse du message reçu en tant que JSON
|
||||
const data = JSON.parse(e.data)
|
||||
|
||||
// Traite le message reçu
|
||||
processMessage(data)
|
||||
})
|
||||
|
||||
// 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(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({
|
||||
'type': 'fetch_channels',
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')) {
|
||||
// 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)
|
||||
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',
|
||||
'message_ids': markReadBuffer,
|
||||
}))
|
||||
markReadBuffer = []
|
||||
markReadTimeout = null
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
@ -2,11 +2,9 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load pipeline %}
|
||||
|
||||
{% block extracss %}
|
||||
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||
<link rel="manifest" href="{% static "chat.webmanifest" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content-title %}{% endblock %}
|
||||
@ -16,6 +14,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
{# Ce script contient toutes les données pour la gestion du chat. #}
|
||||
{% javascript 'chat' %}
|
||||
{# This script contains all data for the chat management #}
|
||||
<script src="{% static 'chat.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
@ -1,40 +1,32 @@
|
||||
{% 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>
|
||||
@ -43,41 +35,32 @@
|
||||
</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 %}
|
||||
{# 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. #}
|
||||
{# Logout button must be present in a form. The form must includes the whole line. #}
|
||||
<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> {# Titre du canal sélectionné #}
|
||||
<span id="channel-title"></span>
|
||||
{% 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>
|
||||
@ -86,12 +69,8 @@
|
||||
{% 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…" %}
|
||||
@ -99,16 +78,12 @@
|
||||
<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>
|
||||
@ -119,8 +94,6 @@
|
||||
</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>
|
@ -1,4 +1,4 @@
|
||||
{% load i18n pipeline static %}
|
||||
{% load i18n static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||
@ -7,29 +7,28 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>
|
||||
{% trans "TFJM² Chat" %}
|
||||
Chat du TFJM²
|
||||
</title>
|
||||
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||
<meta name="description" content="Chat du TFJM²">
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap + Font Awesome CSS #}
|
||||
{% stylesheet 'bootstrap_fontawesome' %}
|
||||
{# Bootstrap CSS #}
|
||||
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
{% javascript 'bootstrap' %}
|
||||
<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 "tfjm/chat.webmanifest" %}">
|
||||
<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 #}
|
||||
{% javascript 'theme' %}
|
||||
{# Inclusion du script gérant le chat #}
|
||||
{% javascript 'chat' %}
|
||||
<script src="{% static 'theme.js' %}"></script>
|
||||
<script src="{% static 'chat.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% load i18n pipeline static %}
|
||||
{% load i18n static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||
@ -7,22 +7,23 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>
|
||||
{% trans "TFJM² Chat" %} - {% trans "Log in" %}
|
||||
Chat du TFJM² - {% trans "Log in" %}
|
||||
</title>
|
||||
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||
<meta name="description" content="Chat du TFJM²">
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap CSS #}
|
||||
{% stylesheet 'bootstrap_fontawesome' %}
|
||||
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
{% javascript 'bootstrap' %}
|
||||
<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 "tfjm/chat.webmanifest" %}">
|
||||
<link rel="manifest" href="{% static "chat.webmanifest" %}">
|
||||
</head>
|
||||
<body class="d-flex w-100 h-100 flex-column">
|
||||
<div class="container">
|
||||
@ -30,7 +31,6 @@
|
||||
{% include "registration/includes/login.html" %}
|
||||
</div>
|
||||
|
||||
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
|
||||
{% javascript 'theme' %}
|
||||
<script src="{% static 'theme.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -60,7 +60,7 @@ Dans le fichier ``docker-compose.yml``, configurer :
|
||||
networks:
|
||||
- tfjm
|
||||
labels:
|
||||
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `inscriptions.tfjm.org`, `plateforme.tfjm.org`)"
|
||||
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `plateforme.tfjm.org`)"
|
||||
- "traefik.http.routers.inscription-tfjm2.entrypoints=websecure"
|
||||
- "traefik.http.routers.inscription-tfjm2.tls.certresolver=mytlschallenge"
|
||||
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load pipeline %}
|
||||
|
||||
{% block content %}
|
||||
{# The navbar to select the tournament #}
|
||||
@ -41,5 +40,5 @@
|
||||
{{ problems|length|json_script:'problems_count' }}
|
||||
|
||||
{# This script contains all data for the draw management #}
|
||||
{% javascript 'draw' %}
|
||||
<script src="{% static 'draw.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: TFJM\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-05-26 22:06+0200\n"
|
||||
"POT-Creation-Date: 2024-04-28 23:37+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@ -21,62 +21,40 @@ msgstr ""
|
||||
msgid "API"
|
||||
msgstr "API"
|
||||
|
||||
#: chat/models.py:22 chat/templates/chat/content.html:23
|
||||
#: chat/models.py:17 chat/templates/chat/content.html:18
|
||||
msgid "General channels"
|
||||
msgstr "Canaux généraux"
|
||||
|
||||
#: chat/models.py:23 chat/templates/chat/content.html:28
|
||||
#: chat/models.py:18 chat/templates/chat/content.html:22
|
||||
msgid "Tournament channels"
|
||||
msgstr "Canaux de tournois"
|
||||
|
||||
#: chat/models.py:24 chat/templates/chat/content.html:33
|
||||
#: chat/models.py:19 chat/templates/chat/content.html:26
|
||||
msgid "Team channels"
|
||||
msgstr "Canaux d'équipes"
|
||||
|
||||
#: chat/models.py:25 chat/templates/chat/content.html:38
|
||||
#: chat/models.py:20 chat/templates/chat/content.html:30
|
||||
msgid "Private channels"
|
||||
msgstr "Messages privés"
|
||||
|
||||
#: chat/models.py:29 participation/models.py:35 participation/models.py:263
|
||||
#: chat/models.py:24 participation/models.py:35 participation/models.py:263
|
||||
#: participation/tables.py:18 participation/tables.py:34
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
#: chat/models.py:30
|
||||
msgid "Visible name of the channel."
|
||||
msgstr "Nom visible du canal."
|
||||
|
||||
#: chat/models.py:35
|
||||
#: chat/models.py:29
|
||||
msgid "category"
|
||||
msgstr "catégorie"
|
||||
|
||||
#: chat/models.py:38
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
"Catégorie du canal, entre canaux généraux, canaux spécifiques à un tournoi, "
|
||||
"canaux d'équipe ou messages privés. Sera utilisé pour trier les canaux dans "
|
||||
"la liste des canaux."
|
||||
|
||||
#: chat/models.py:44
|
||||
#: chat/models.py:36
|
||||
msgid "read permission"
|
||||
msgstr "permission de lecture"
|
||||
|
||||
#: chat/models.py:46
|
||||
msgid "Permission type that is required to read the messages of the channels."
|
||||
msgstr "Type de permission nécessaire pour lire les messages des canaux."
|
||||
|
||||
#: chat/models.py:51
|
||||
#: chat/models.py:42
|
||||
msgid "write permission"
|
||||
msgstr "permission d'écriture"
|
||||
|
||||
#: chat/models.py:53
|
||||
msgid "Permission type that is required to write a message to a channel."
|
||||
msgstr "Type de permission nécessaire pour écrire un message dans un canal."
|
||||
|
||||
#: chat/models.py:62 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88
|
||||
#: chat/models.py:52 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88
|
||||
#: draw/models.py:26 participation/admin.py:79 participation/admin.py:140
|
||||
#: participation/admin.py:171 participation/models.py:693
|
||||
#: participation/models.py:717 participation/models.py:935
|
||||
@ -85,7 +63,7 @@ msgstr "Type de permission nécessaire pour écrire un message dans un canal."
|
||||
msgid "tournament"
|
||||
msgstr "tournoi"
|
||||
|
||||
#: chat/models.py:64
|
||||
#: chat/models.py:54
|
||||
msgid ""
|
||||
"For a permission that concerns a tournament, indicates what is the concerned "
|
||||
"tournament."
|
||||
@ -93,21 +71,21 @@ msgstr ""
|
||||
"Pour une permission qui concerne un tournoi, indique quel est le tournoi "
|
||||
"concerné."
|
||||
|
||||
#: chat/models.py:73 draw/models.py:429 draw/models.py:456
|
||||
#: chat/models.py:63 draw/models.py:429 draw/models.py:456
|
||||
#: participation/admin.py:136 participation/admin.py:155
|
||||
#: participation/models.py:1434 participation/models.py:1443
|
||||
#: participation/tables.py:84
|
||||
msgid "pool"
|
||||
msgstr "poule"
|
||||
|
||||
#: chat/models.py:75
|
||||
#: chat/models.py:65
|
||||
msgid ""
|
||||
"For a permission that concerns a pool, indicates what is the concerned pool."
|
||||
msgstr ""
|
||||
"Pour une permission qui concerne une poule, indique quelle est la poule "
|
||||
"concernée."
|
||||
|
||||
#: chat/models.py:84 draw/templates/draw/tournament_content.html:277
|
||||
#: chat/models.py:74 draw/templates/draw/tournament_content.html:277
|
||||
#: participation/admin.py:167 participation/models.py:252
|
||||
#: participation/models.py:708
|
||||
#: participation/templates/participation/tournament_harmonize.html:15
|
||||
@ -117,18 +95,18 @@ msgstr ""
|
||||
msgid "team"
|
||||
msgstr "équipe"
|
||||
|
||||
#: chat/models.py:86
|
||||
#: chat/models.py:76
|
||||
msgid ""
|
||||
"For a permission that concerns a team, indicates what is the concerned team."
|
||||
msgstr ""
|
||||
"Pour une permission qui concerne une équipe, indique quelle est l'équipe "
|
||||
"concernée."
|
||||
|
||||
#: chat/models.py:90
|
||||
#: chat/models.py:80
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
|
||||
#: chat/models.py:92
|
||||
#: chat/models.py:82
|
||||
msgid ""
|
||||
"If checked, only users who have been explicitly added to the channel will be "
|
||||
"able to access it."
|
||||
@ -136,11 +114,11 @@ msgstr ""
|
||||
"Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement "
|
||||
"ajouté⋅es au canal pourront y accéder."
|
||||
|
||||
#: chat/models.py:97
|
||||
#: chat/models.py:87
|
||||
msgid "invited users"
|
||||
msgstr "Utilisateur⋅rices invité"
|
||||
|
||||
#: chat/models.py:100
|
||||
#: chat/models.py:90
|
||||
msgid ""
|
||||
"Extra users who have been invited to the channel, in addition to the "
|
||||
"permitted group of the channel."
|
||||
@ -148,64 +126,64 @@ msgstr ""
|
||||
"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du "
|
||||
"groupe autorisé du canal."
|
||||
|
||||
#: chat/models.py:122
|
||||
#: chat/models.py:102
|
||||
#, python-brace-format
|
||||
msgid "Channel {name}"
|
||||
msgstr "Canal {name}"
|
||||
|
||||
#: chat/models.py:231 chat/models.py:246
|
||||
#: chat/models.py:168 chat/models.py:177
|
||||
msgid "channel"
|
||||
msgstr "canal"
|
||||
|
||||
#: chat/models.py:232
|
||||
#: chat/models.py:169
|
||||
msgid "channels"
|
||||
msgstr "canaux"
|
||||
|
||||
#: chat/models.py:252
|
||||
#: chat/models.py:183
|
||||
msgid "author"
|
||||
msgstr "auteur⋅rice"
|
||||
|
||||
#: chat/models.py:259
|
||||
#: chat/models.py:190
|
||||
msgid "created at"
|
||||
msgstr "créé le"
|
||||
|
||||
#: chat/models.py:264
|
||||
#: chat/models.py:195
|
||||
msgid "updated at"
|
||||
msgstr "modifié le"
|
||||
|
||||
#: chat/models.py:269
|
||||
#: chat/models.py:200
|
||||
msgid "content"
|
||||
msgstr "contenu"
|
||||
|
||||
#: chat/models.py:274
|
||||
#: chat/models.py:205
|
||||
msgid "users read"
|
||||
msgstr "utilisateur⋅rices ayant lu"
|
||||
|
||||
#: chat/models.py:277
|
||||
#: chat/models.py:208
|
||||
msgid "Users who have read the message."
|
||||
msgstr "Utilisateur⋅rices qui ont lu le message."
|
||||
|
||||
#: chat/models.py:363
|
||||
#: chat/models.py:271
|
||||
msgid "message"
|
||||
msgstr "message"
|
||||
|
||||
#: chat/models.py:364
|
||||
#: chat/models.py:272
|
||||
msgid "messages"
|
||||
msgstr "messages"
|
||||
|
||||
#: chat/templates/chat/content.html:5
|
||||
#: chat/templates/chat/content.html:4
|
||||
msgid "JavaScript must be enabled on your browser to access chat."
|
||||
msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat."
|
||||
|
||||
#: chat/templates/chat/content.html:10
|
||||
#: chat/templates/chat/content.html:8
|
||||
msgid "Chat channels"
|
||||
msgstr "Canaux de chat"
|
||||
|
||||
#: chat/templates/chat/content.html:17
|
||||
#: chat/templates/chat/content.html:14
|
||||
msgid "Sort by unread messages"
|
||||
msgstr "Trier par messages non lus"
|
||||
|
||||
#: chat/templates/chat/content.html:47
|
||||
#: chat/templates/chat/content.html:38
|
||||
msgid ""
|
||||
"You can install a shortcut to the chat on your home screen using the "
|
||||
"download button on the header."
|
||||
@ -213,33 +191,27 @@ msgstr ""
|
||||
"Vous pouvez installer un raccourci vers le chat sur votre écran d'accueil en "
|
||||
"utilisant le bouton de téléchargement dans l'en-tête."
|
||||
|
||||
#: chat/templates/chat/content.html:71
|
||||
#: chat/templates/chat/content.html:56
|
||||
msgid "Toggle fullscreen mode"
|
||||
msgstr "Inverse le mode plein écran"
|
||||
|
||||
#: chat/templates/chat/content.html:76 tfjm/templates/navbar.html:117
|
||||
#: chat/templates/chat/content.html:60 tfjm/templates/navbar.html:117
|
||||
msgid "Log out"
|
||||
msgstr "Déconnexion"
|
||||
|
||||
#: chat/templates/chat/content.html:81
|
||||
#: chat/templates/chat/content.html:64
|
||||
msgid "Install app on home screen"
|
||||
msgstr "Installer l'application sur l'écran d'accueil"
|
||||
|
||||
#: chat/templates/chat/content.html:97
|
||||
#: chat/templates/chat/content.html:76
|
||||
msgid "Fetch previous messages…"
|
||||
msgstr "Récupérer les messages précédents…"
|
||||
|
||||
#: chat/templates/chat/content.html:112
|
||||
#: chat/templates/chat/content.html:87
|
||||
msgid "Send message…"
|
||||
msgstr "Envoyer un message…"
|
||||
|
||||
#: chat/templates/chat/fullscreen.html:10
|
||||
#: chat/templates/chat/fullscreen.html:12 chat/templates/chat/login.html:10
|
||||
#: chat/templates/chat/login.html:12
|
||||
msgid "TFJM² Chat"
|
||||
msgstr "Chat du TFJM²"
|
||||
|
||||
#: chat/templates/chat/login.html:10 chat/templates/chat/login.html:31
|
||||
#: chat/templates/chat/login.html:10 chat/templates/chat/login.html:30
|
||||
#: registration/templates/registration/password_reset_complete.html:10
|
||||
#: tfjm/templates/base.html:84 tfjm/templates/base.html:85
|
||||
#: tfjm/templates/navbar.html:98
|
||||
@ -603,8 +575,8 @@ msgstr "Êtes-vous sûr·e de vouloir annuler le tirage au sort ?"
|
||||
msgid "Close"
|
||||
msgstr "Fermer"
|
||||
|
||||
#: draw/views.py:31 participation/views.py:162 participation/views.py:504
|
||||
#: participation/views.py:535
|
||||
#: draw/views.py:31 participation/views.py:162 participation/views.py:501
|
||||
#: participation/views.py:532
|
||||
msgid "You are not in a team."
|
||||
msgstr "Vous n'êtes pas dans une équipe."
|
||||
|
||||
@ -716,7 +688,7 @@ msgstr "Ce trigramme est déjà utilisé."
|
||||
msgid "No team was found with this access code."
|
||||
msgstr "Aucune équipe n'a été trouvée avec ce code d'accès."
|
||||
|
||||
#: participation/forms.py:58 participation/views.py:506
|
||||
#: participation/forms.py:58 participation/views.py:503
|
||||
msgid "The team is already validated or the validation is pending."
|
||||
msgstr "La validation de l'équipe est déjà faite ou en cours."
|
||||
|
||||
@ -1896,7 +1868,7 @@ msgid "Invalidate"
|
||||
msgstr "Invalider"
|
||||
|
||||
#: participation/templates/participation/team_detail.html:237
|
||||
#: participation/views.py:336
|
||||
#: participation/views.py:333
|
||||
msgid "Upload motivation letter"
|
||||
msgstr "Envoyer la lettre de motivation"
|
||||
|
||||
@ -1905,7 +1877,7 @@ msgid "Update team"
|
||||
msgstr "Modifier l'équipe"
|
||||
|
||||
#: participation/templates/participation/team_detail.html:247
|
||||
#: participation/views.py:498
|
||||
#: participation/views.py:495
|
||||
msgid "Leave team"
|
||||
msgstr "Quitter l'équipe"
|
||||
|
||||
@ -2101,7 +2073,7 @@ msgstr "Vous êtes déjà dans une équipe."
|
||||
msgid "Join team"
|
||||
msgstr "Rejoindre une équipe"
|
||||
|
||||
#: participation/views.py:163 participation/views.py:536
|
||||
#: participation/views.py:163 participation/views.py:533
|
||||
msgid "You don't participate, so you don't have any team."
|
||||
msgstr "Vous ne participez pas, vous n'avez donc pas d'équipe."
|
||||
|
||||
@ -2137,169 +2109,169 @@ msgstr "Vous n'êtes pas un⋅e organisateur⋅rice du tournoi."
|
||||
msgid "This team has no pending validation."
|
||||
msgstr "L'équipe n'a pas de validation en attente."
|
||||
|
||||
#: participation/views.py:279
|
||||
#: participation/views.py:276
|
||||
msgid "You must specify if you validate the registration or not."
|
||||
msgstr "Vous devez spécifier si vous validez l'inscription ou non."
|
||||
|
||||
#: participation/views.py:314
|
||||
#: participation/views.py:311
|
||||
#, python-brace-format
|
||||
msgid "Update team {trigram}"
|
||||
msgstr "Mise à jour de l'équipe {trigram}"
|
||||
|
||||
#: participation/views.py:375 participation/views.py:483
|
||||
#: participation/views.py:372 participation/views.py:480
|
||||
#, python-brace-format
|
||||
msgid "Motivation letter of {team}.{ext}"
|
||||
msgstr "Lettre de motivation de {team}.{ext}"
|
||||
|
||||
#: participation/views.py:408
|
||||
#: participation/views.py:405
|
||||
#, python-brace-format
|
||||
msgid "Authorizations of team {trigram}.zip"
|
||||
msgstr "Autorisations de l'équipe {trigram}.zip"
|
||||
|
||||
#: participation/views.py:412
|
||||
#: participation/views.py:409
|
||||
#, python-brace-format
|
||||
msgid "Authorizations of {tournament}.zip"
|
||||
msgstr "Autorisations du tournoi {tournament}.zip"
|
||||
|
||||
#: participation/views.py:431
|
||||
#: participation/views.py:428
|
||||
#, python-brace-format
|
||||
msgid "Photo authorization of {participant}.{ext}"
|
||||
msgstr "Autorisation de droit à l'image de {participant}.{ext}"
|
||||
|
||||
#: participation/views.py:440
|
||||
#: participation/views.py:437
|
||||
#, python-brace-format
|
||||
msgid "Parental authorization of {participant}.{ext}"
|
||||
msgstr "Autorisation parentale de {participant}.{ext}"
|
||||
|
||||
#: participation/views.py:448
|
||||
#: participation/views.py:445
|
||||
#, python-brace-format
|
||||
msgid "Health sheet of {participant}.{ext}"
|
||||
msgstr "Fiche sanitaire de {participant}.{ext}"
|
||||
|
||||
#: participation/views.py:456
|
||||
#: participation/views.py:453
|
||||
#, python-brace-format
|
||||
msgid "Vaccine sheet of {participant}.{ext}"
|
||||
msgstr "Carnet de vaccination de {participant}.{ext}"
|
||||
|
||||
#: participation/views.py:467
|
||||
#: participation/views.py:464
|
||||
#, python-brace-format
|
||||
msgid "Photo authorization of {participant} (final).{ext}"
|
||||
msgstr "Autorisation de droit à l'image de {participant} (finale).{ext}"
|
||||
|
||||
#: participation/views.py:476
|
||||
#: participation/views.py:473
|
||||
#, python-brace-format
|
||||
msgid "Parental authorization of {participant} (final).{ext}"
|
||||
msgstr "Autorisation parentale de {participant} (finale).{ext}"
|
||||
|
||||
#: participation/views.py:550
|
||||
#: participation/views.py:547
|
||||
msgid "The team is not validated yet."
|
||||
msgstr "L'équipe n'est pas encore validée."
|
||||
|
||||
#: participation/views.py:564
|
||||
#: participation/views.py:561
|
||||
#, python-brace-format
|
||||
msgid "Participation of team {trigram}"
|
||||
msgstr "Participation de l'équipe {trigram}"
|
||||
|
||||
#: participation/views.py:652
|
||||
#: participation/views.py:649
|
||||
#, python-brace-format
|
||||
msgid "Payments of {tournament}"
|
||||
msgstr "Paiements de {tournament}"
|
||||
|
||||
#: participation/views.py:751
|
||||
#: participation/views.py:748
|
||||
msgid "Notes published!"
|
||||
msgstr "Notes publiées !"
|
||||
|
||||
#: participation/views.py:753
|
||||
#: participation/views.py:750
|
||||
msgid "Notes hidden!"
|
||||
msgstr "Notes dissimulées !"
|
||||
|
||||
#: participation/views.py:784
|
||||
#: participation/views.py:781
|
||||
#, python-brace-format
|
||||
msgid "Harmonize notes of {tournament} - Day {round}"
|
||||
msgstr "Harmoniser les notes de {tournament} - Jour {round}"
|
||||
|
||||
#: participation/views.py:897
|
||||
#: participation/views.py:894
|
||||
msgid "You can't upload a solution after the deadline."
|
||||
msgstr "Vous ne pouvez pas envoyer de solution après la date limite."
|
||||
|
||||
#: participation/views.py:1017
|
||||
#: participation/views.py:1014
|
||||
#, python-brace-format
|
||||
msgid "Solutions of team {trigram}.zip"
|
||||
msgstr "Solutions de l'équipe {trigram}.zip"
|
||||
|
||||
#: participation/views.py:1017
|
||||
#: participation/views.py:1014
|
||||
#, python-brace-format
|
||||
msgid "Syntheses of team {trigram}.zip"
|
||||
msgstr "Notes de synthèse de l'équipe {trigram}.zip"
|
||||
|
||||
#: participation/views.py:1034 participation/views.py:1049
|
||||
#: participation/views.py:1031 participation/views.py:1046
|
||||
#, python-brace-format
|
||||
msgid "Solutions of {tournament}.zip"
|
||||
msgstr "Solutions de {tournament}.zip"
|
||||
|
||||
#: participation/views.py:1034 participation/views.py:1049
|
||||
#: participation/views.py:1031 participation/views.py:1046
|
||||
#, python-brace-format
|
||||
msgid "Syntheses of {tournament}.zip"
|
||||
msgstr "Notes de synthèse de {tournament}.zip"
|
||||
|
||||
#: participation/views.py:1058
|
||||
#: participation/views.py:1055
|
||||
#, python-brace-format
|
||||
msgid "Solutions for pool {pool} of tournament {tournament}.zip"
|
||||
msgstr "Solutions pour la poule {pool} du tournoi {tournament}.zip"
|
||||
|
||||
#: participation/views.py:1059
|
||||
#: participation/views.py:1056
|
||||
#, python-brace-format
|
||||
msgid "Syntheses for pool {pool} of tournament {tournament}.zip"
|
||||
msgstr "Notes de synthèses pour la poule {pool} du tournoi {tournament}.zip"
|
||||
|
||||
#: participation/views.py:1101
|
||||
#: participation/views.py:1098
|
||||
#, python-brace-format
|
||||
msgid "Jury of pool {pool} for {tournament} with teams {teams}"
|
||||
msgstr "Jury de la poule {pool} pour {tournament} avec les équipes {teams}"
|
||||
|
||||
#: participation/views.py:1117
|
||||
#: participation/views.py:1114
|
||||
#, python-brace-format
|
||||
msgid "The jury {name} is already in the pool!"
|
||||
msgstr "{name} est déjà dans la poule !"
|
||||
|
||||
#: participation/views.py:1137
|
||||
#: participation/views.py:1134
|
||||
msgid "New TFJM² jury account"
|
||||
msgstr "Nouveau compte de juré⋅e pour le TFJM²"
|
||||
|
||||
#: participation/views.py:1158
|
||||
#: participation/views.py:1155
|
||||
#, python-brace-format
|
||||
msgid "The jury {name} has been successfully added!"
|
||||
msgstr "{name} a été ajouté⋅e avec succès en tant que juré⋅e !"
|
||||
|
||||
#: participation/views.py:1194
|
||||
#: participation/views.py:1191
|
||||
#, python-brace-format
|
||||
msgid "The jury {name} has been successfully removed!"
|
||||
msgstr "{name} a été retiré⋅e avec succès du jury !"
|
||||
|
||||
#: participation/views.py:1220
|
||||
#: participation/views.py:1217
|
||||
#, python-brace-format
|
||||
msgid "The jury {name} has been successfully promoted president!"
|
||||
msgstr "{name} a été nommé⋅e président⋅e du jury !"
|
||||
|
||||
#: participation/views.py:1248
|
||||
#: participation/views.py:1245
|
||||
msgid "The following user is not registered as a jury:"
|
||||
msgstr "L'utilisateur⋅rice suivant n'est pas inscrit⋅e en tant que juré⋅e :"
|
||||
|
||||
#: participation/views.py:1264
|
||||
#: participation/views.py:1261
|
||||
msgid "Notes were successfully uploaded."
|
||||
msgstr "Les notes ont bien été envoyées."
|
||||
|
||||
#: participation/views.py:1842
|
||||
#: participation/views.py:1839
|
||||
#, python-brace-format
|
||||
msgid "Notation sheets of pool {pool} of {tournament}.zip"
|
||||
msgstr "Feuilles de notations pour la poule {pool} du tournoi {tournament}.zip"
|
||||
|
||||
#: participation/views.py:1847
|
||||
#: participation/views.py:1844
|
||||
#, python-brace-format
|
||||
msgid "Notation sheets of {tournament}.zip"
|
||||
msgstr "Feuilles de notation de {tournament}.zip"
|
||||
|
||||
#: participation/views.py:2012
|
||||
#: participation/views.py:2009
|
||||
msgid "You can't upload a synthesis after the deadline."
|
||||
msgstr "Vous ne pouvez pas envoyer de note de synthèse après la date limite."
|
||||
|
||||
|
@ -7,10 +7,10 @@
|
||||
<div id="form-content">
|
||||
<div class="alert alert-info">
|
||||
{% trans "Templates:" %}
|
||||
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.pdf" %}"> PDF</a> —
|
||||
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.tex" %}"> TEX</a> —
|
||||
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.odt" %}"> ODT</a> —
|
||||
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a> —
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a> —
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.odt" %}"> ODT</a> —
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
@ -9,7 +9,7 @@
|
||||
<div id="form-content">
|
||||
<div class="alert alert-info">
|
||||
{% trans "Health sheet template:" %}
|
||||
<a class="alert-link" href="{% static "tfjm/Fiche_sanitaire.pdf" %}">{% trans "Download" %}</a>
|
||||
<a class="alert-link" href="{% static "Fiche_sanitaire.pdf" %}">{% trans "Download" %}</a>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
@ -5,15 +5,14 @@ Django>=5.0.3,<6.0
|
||||
django-crispy-forms~=2.1
|
||||
django-extensions~=3.2.3
|
||||
django-filter~=23.5
|
||||
git+https://github.com/django-haystack/django-haystack.git#v3.3b2
|
||||
elasticsearch~=7.17.9
|
||||
git+https://github.com/django-haystack/django-haystack.git#v3.3b1
|
||||
django-mailer~=2.3.1
|
||||
django-phonenumber-field~=7.3.0
|
||||
django-pipeline~=3.1.0
|
||||
django-polymorphic~=3.1.0
|
||||
django-tables2~=2.7.0
|
||||
djangorestframework~=3.14.0
|
||||
django-rest-polymorphic~=0.1.10
|
||||
elasticsearch~=7.17.9
|
||||
gspread~=6.1.0
|
||||
gunicorn~=21.2.0
|
||||
odfpy~=1.4.1
|
||||
|
@ -7,10 +7,10 @@ Django settings for tfjm project.
|
||||
Generated by 'django-admin startproject' using Django 3.0.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/topics/settings/
|
||||
https://docs.djangoproject.com/en/3.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.0/ref/settings/
|
||||
https://docs.djangoproject.com/en/3.0/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -25,7 +25,7 @@ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
||||
ADMINS = [("Emmy D'Anello", "emmy.danello@animath.fr")]
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS')
|
||||
@ -63,7 +63,6 @@ INSTALLED_APPS = [
|
||||
'haystack',
|
||||
'logs',
|
||||
'phonenumber_field',
|
||||
'pipeline',
|
||||
'polymorphic',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
@ -96,8 +95,6 @@ MIDDLEWARE = [
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.contrib.sites.middleware.CurrentSiteMiddleware',
|
||||
'django.middleware.gzip.GZipMiddleware',
|
||||
'pipeline.middleware.MinifyHTMLMiddleware',
|
||||
'tfjm.middlewares.SessionMiddleware',
|
||||
'tfjm.middlewares.FetchMiddleware',
|
||||
]
|
||||
@ -129,7 +126,7 @@ ASGI_APPLICATION = 'tfjm.asgi.application'
|
||||
WSGI_APPLICATION = 'tfjm.wsgi.application'
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
@ -164,7 +161,7 @@ REST_FRAMEWORK = {
|
||||
}
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||
# https://docs.djangoproject.com/en/3.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en'
|
||||
|
||||
@ -184,7 +181,7 @@ USE_TZ = True
|
||||
LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")]
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||
# https://docs.djangoproject.com/en/3.0/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
@ -194,70 +191,6 @@ STATICFILES_DIRS = [
|
||||
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
|
||||
STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage'
|
||||
|
||||
STATICFILES_FINDERS = (
|
||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||
'pipeline.finders.PipelineFinder',
|
||||
)
|
||||
|
||||
PIPELINE = {
|
||||
'DISABLE_WRAPPER': True,
|
||||
'JAVASCRIPT': {
|
||||
'bootstrap': {
|
||||
'source_filenames': {
|
||||
'bootstrap/js/bootstrap.bundle.min.js',
|
||||
},
|
||||
'output_filename': 'tfjm/js/bootstrap.bundle.min.js',
|
||||
},
|
||||
'bootstrap_select': {
|
||||
'source_filenames': {
|
||||
'jquery/jquery.min.js',
|
||||
'bootstrap-select/js/bootstrap-select.min.js',
|
||||
'bootstrap-select/js/defaults-fr_FR.min.js',
|
||||
},
|
||||
'output_filename': 'tfjm/js/bootstrap-select-jquery.min.js',
|
||||
},
|
||||
'main': {
|
||||
'source_filenames': (
|
||||
'tfjm/js/main.js',
|
||||
'tfjm/js/theme.js',
|
||||
),
|
||||
'output_filename': 'tfjm/js/main.min.js',
|
||||
},
|
||||
'theme': {
|
||||
'source_filenames': (
|
||||
'tfjm/js/theme.js',
|
||||
),
|
||||
'output_filename': 'tfjm/js/theme.min.js',
|
||||
},
|
||||
'chat': {
|
||||
'source_filenames': (
|
||||
'tfjm/js/chat.js',
|
||||
),
|
||||
'output_filename': 'tfjm/js/chat.min.js',
|
||||
},
|
||||
'draw': {
|
||||
'source_filenames': (
|
||||
'tfjm/js/draw.js',
|
||||
),
|
||||
'output_filename': 'tfjm/js/draw.min.js',
|
||||
},
|
||||
},
|
||||
'STYLESHEETS': {
|
||||
'bootstrap_fontawesome': {
|
||||
'source_filenames': (
|
||||
'bootstrap/css/bootstrap.min.css',
|
||||
'fontawesome/css/all.css',
|
||||
'fontawesome/css/v4-shims.css',
|
||||
'bootstrap-select/css/bootstrap-select.min.css',
|
||||
),
|
||||
'output_filename': 'tfjm/css/bootstrap_fontawesome.min.css',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
|
@ -7,7 +7,7 @@ import os
|
||||
DEBUG = False
|
||||
|
||||
# Mandatory !
|
||||
ALLOWED_HOSTS = ['inscription.tfjm.org', 'inscriptions.tfjm.org', 'plateforme.tfjm.org']
|
||||
ALLOWED_HOSTS = ['inscription.tfjm.org', 'plateforme.tfjm.org']
|
||||
|
||||
# Emails
|
||||
EMAIL_BACKEND = 'mailer.backend.DbBackend'
|
||||
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 428 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
@ -1,6 +1,4 @@
|
||||
{% load i18n %}
|
||||
{% load pipeline %}
|
||||
{% load static %}
|
||||
{% load i18n static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||
@ -18,12 +16,19 @@
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap CSS #}
|
||||
{% stylesheet 'bootstrap_fontawesome' %}
|
||||
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
|
||||
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-select/css/bootstrap-select.min.css' %}">
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
{% javascript 'bootstrap' %}
|
||||
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
|
||||
|
||||
{# bootstrap-select for beautiful selects and JQuery dependency #}
|
||||
{% javascript 'bootstrap_select' %}
|
||||
<script src="{% static 'jquery/jquery.min.js' %}"></script>
|
||||
<script src="{% static 'bootstrap-select/js/bootstrap-select.min.js' %}"></script>
|
||||
<script src="{% static 'bootstrap-select/js/defaults-fr_FR.min.js' %}"></script>
|
||||
|
||||
{# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #}
|
||||
{% if form.media %}
|
||||
@ -82,7 +87,8 @@
|
||||
{% include "base_modal.html" with modal_id="login" %}
|
||||
{% endif %}
|
||||
|
||||
{% javascript 'main' %}
|
||||
<script src="{% static 'main.js' %}"></script>
|
||||
<script src="{% static 'theme.js' %}"></script>
|
||||
|
||||
<script>
|
||||
CSRF_TOKEN = "{{ csrf_token }}";
|
||||
|
@ -3,7 +3,7 @@
|
||||
<nav class="navbar navbar-expand-lg fixed-navbar shadow-sm">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="https://tfjm.org/">
|
||||
<img src="{% static "tfjm/img/tfjm.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo">
|
||||
<img src="{% static "tfjm.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNavDropdown"
|
||||
|