1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-05-17 10:12:47 +00:00

Compare commits

...

9 Commits

Author SHA1 Message Date
Emmy D'Anello
b820306e2e
Automatically create appropriated channels when tournaments/pools/participations are updated
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 17:04:34 +02:00
Emmy D'Anello
756f2074b3
Editing and deleting is working
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 16:56:30 +02:00
Emmy D'Anello
6385e53425
Users can only edit & delete their own messages (except for admin users)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 16:36:54 +02:00
Emmy D'Anello
32b816a4de
Add popovers to edit and delete messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 16:28:39 +02:00
Emmy D'Anello
41ccbcb277
Only give the focus to a private channel if it wasn't previously created
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 15:37:45 +02:00
Emmy D'Anello
2438bb9bcc
Manage private chats
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 15:35:32 +02:00
Emmy D'Anello
ddd2280ae4
Reset retry delay to 1 second when a connection has succeeded
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 15:09:52 +02:00
Emmy D'Anello
4958628e40
Extend session cookie age from 3 hours to 2 weeks
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 15:04:15 +02:00
Emmy D'Anello
731d309305
Add script to create channels per tournament, pools and teams. Put channels in categories
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 13:50:04 +02:00
14 changed files with 641 additions and 52 deletions

View File

@ -8,8 +8,8 @@ from .models import Channel, Message
@admin.register(Channel) @admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin): class ChannelAdmin(admin.ModelAdmin):
list_display = ('name', 'read_access', 'write_access', 'tournament', 'pool', 'team', 'private',) list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',)
list_filter = ('read_access', 'write_access', 'tournament', 'private',) list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',)
search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',) search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',)
autocomplete_fields = ('tournament', 'pool', 'team', 'invited', ) autocomplete_fields = ('tournament', 'pool', 'team', 'invited', )

View File

@ -2,8 +2,15 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_save
class ChatConfig(AppConfig): class ChatConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "chat" name = "chat"
def ready(self):
from chat import signals
post_save.connect(signals.create_tournament_channels, "participation.Tournament")
post_save.connect(signals.create_pool_channels, "participation.Pool")
post_save.connect(signals.create_team_channel, "participation.Participation")

View File

@ -3,7 +3,6 @@
from channels.generic.websocket import AsyncJsonWebsocketConsumer from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.auth.models import User from django.contrib.auth.models import User
from participation.models import Team, Pool, Tournament
from registration.models import Registration from registration.models import Registration
from .models import Channel, Message from .models import Channel, Message
@ -38,6 +37,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
channels = await Channel.get_accessible_channels(user, 'read') channels = await Channel.get_accessible_channels(user, 'read')
async for channel in channels.all(): async for channel in channels.all():
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
async def disconnect(self, close_code) -> None: async def disconnect(self, close_code) -> None:
""" """
@ -51,6 +51,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
channels = await Channel.get_accessible_channels(self.scope['user'], 'read') channels = await Channel.get_accessible_channels(self.scope['user'], 'read')
async for channel in channels.all(): async for channel in channels.all():
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name) await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
async def receive_json(self, content, **kwargs): async def receive_json(self, content, **kwargs):
""" """
@ -61,9 +62,15 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
case 'fetch_channels': case 'fetch_channels':
await self.fetch_channels() await self.fetch_channels()
case 'send_message': case 'send_message':
await self.receive_message(content) await self.receive_message(**content)
case 'edit_message':
await self.edit_message(**content)
case 'delete_message':
await self.delete_message(**content)
case 'fetch_messages': case 'fetch_messages':
await self.fetch_messages(**content) await self.fetch_messages(**content)
case 'start_private_chat':
await self.start_private_chat(**content)
case unknown: case unknown:
print("Unknown message type:", unknown) print("Unknown message type:", unknown)
@ -77,19 +84,20 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
'channels': [ 'channels': [
{ {
'id': channel.id, 'id': channel.id,
'name': channel.name, 'name': channel.get_visible_name(user),
'category': channel.category,
'read_access': True, 'read_access': True,
'write_access': await write_channels.acontains(channel), 'write_access': await write_channels.acontains(channel),
} }
async for channel in read_channels.all() async for channel in read_channels.prefetch_related('invited').all()
] ]
} }
await self.send_json(message) await self.send_json(message)
async def receive_message(self, message: dict) -> None: async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
user = self.scope['user'] user = self.scope['user']
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \ channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
.aget(id=message['channel_id']) .aget(id=channel_id)
write_channels = await Channel.get_accessible_channels(user, 'write') write_channels = await Channel.get_accessible_channels(user, 'write')
if not await write_channels.acontains(channel): if not await write_channels.acontains(channel):
return return
@ -97,7 +105,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
message = await Message.objects.acreate( message = await Message.objects.acreate(
author=user, author=user,
channel=channel, channel=channel,
content=message['content'], content=content,
) )
await self.channel_layer.group_send(f'chat-{channel.id}', { await self.channel_layer.group_send(f'chat-{channel.id}', {
@ -105,10 +113,41 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
'id': message.id, 'id': message.id,
'channel_id': channel.id, 'channel_id': channel.id,
'timestamp': message.created_at.isoformat(), 'timestamp': message.created_at.isoformat(),
'author_id': message.author_id,
'author': await message.aget_author_name(), 'author': await message.aget_author_name(),
'content': message.content, 'content': message.content,
}) })
async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
message = await Message.objects.aget(id=message_id)
user = self.scope['user']
if user.id != message.author_id and not user.is_superuser:
return
message.content = content
await message.asave()
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
'type': 'chat.edit_message',
'id': message_id,
'channel_id': message.channel_id,
'content': content,
})
async def delete_message(self, message_id: int, **kwargs) -> None:
message = await Message.objects.aget(id=message_id)
user = self.scope['user']
if user.id != message.author_id and not user.is_superuser:
return
await message.adelete()
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
'type': 'chat.delete_message',
'id': message_id,
'channel_id': message.channel_id,
})
async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None: async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
channel = await Channel.objects.aget(id=channel_id) channel = await Channel.objects.aget(id=channel_id)
read_channels = await Channel.get_accessible_channels(self.scope['user'], 'read') read_channels = await Channel.get_accessible_channels(self.scope['user'], 'read')
@ -125,6 +164,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
{ {
'id': message.id, 'id': message.id,
'timestamp': message.created_at.isoformat(), 'timestamp': message.created_at.isoformat(),
'author_id': message.author_id,
'author': await message.aget_author_name(), 'author': await message.aget_author_name(),
'content': message.content, 'content': message.content,
} }
@ -132,7 +172,59 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
])) ]))
}) })
async def start_private_chat(self, user_id: int, **kwargs) -> None:
user = self.scope['user']
other_user = await User.objects.aget(id=user_id)
channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
if not await channel_qs.aexists():
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,
private=True,
)
await channel.invited.aset([user, other_user])
await channel.asave()
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
if user != other_user:
await self.channel_layer.group_send(f"user-{other_user.id}", {
'type': 'chat.start_private_chat',
'channel': {
'id': channel.id,
'name': f"{user.first_name} {user.last_name}",
'category': channel.category,
'read_access': True,
'write_access': True,
}
})
else:
channel = await channel_qs.afirst()
await self.channel_layer.group_send(f"user-{user.id}", {
'type': 'chat.start_private_chat',
'channel': {
'id': channel.id,
'name': f"{other_user.first_name} {other_user.last_name}",
'category': channel.category,
'read_access': True,
'write_access': True,
}
})
async def chat_send_message(self, message) -> None: async def chat_send_message(self, message) -> None:
await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'], await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
'timestamp': message['timestamp'], 'author': message['author'], 'timestamp': message['timestamp'], 'author': message['author'],
'content': message['content']}) 'content': message['content']})
async def chat_edit_message(self, message) -> None:
print(message)
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:
await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
async def chat_start_private_chat(self, message) -> None:
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']})

View File

View File

View File

@ -0,0 +1,143 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.management import BaseCommand
from django.utils.translation import activate
from participation.models import Team, Tournament
from tfjm.permissions import PermissionType
from ...models import Channel
class Command(BaseCommand):
def handle(self, *args, **kwargs):
activate('fr')
Channel.objects.update_or_create(
name="Annonces",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.ADMIN,
),
)
Channel.objects.update_or_create(
name="Aide jurys et orgas",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.VOLUNTEER,
write_access=PermissionType.VOLUNTEER,
),
)
Channel.objects.update_or_create(
name="Général",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.AUTHENTICATED,
),
)
Channel.objects.update_or_create(
name="Je cherche une équipe",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.AUTHENTICATED,
),
)
Channel.objects.update_or_create(
name="Détente",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.AUTHENTICATED,
),
)
for tournament in Tournament.objects.all():
Channel.objects.update_or_create(
name=f"{tournament.name} - Annonces",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_ORGANIZER,
tournament=tournament,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Général",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_MEMBER,
tournament=tournament,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Détente",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_MEMBER,
tournament=tournament,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Juré⋅es",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.JURY_MEMBER,
write_access=PermissionType.JURY_MEMBER,
tournament=tournament,
),
)
if tournament.remote:
Channel.objects.update_or_create(
name=f"{tournament.name} - Président⋅es de jury",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
tournament=tournament,
),
)
for pool in tournament.pools.all():
Channel.objects.update_or_create(
name=f"{tournament.name} - Poule {pool.short_name}",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.POOL_MEMBER,
write_access=PermissionType.POOL_MEMBER,
pool=pool,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.JURY_MEMBER,
write_access=PermissionType.JURY_MEMBER,
pool=pool,
),
)
for team in Team.objects.filter(participation__valid=True).all():
Channel.objects.update_or_create(
name=f"Équipe {team.trigram}",
defaults=dict(
category=Channel.ChannelCategory.TEAM,
read_access=PermissionType.TEAM_MEMBER,
write_access=PermissionType.TEAM_MEMBER,
team=team,
),
)

View File

@ -0,0 +1,36 @@
# Generated by Django 5.0.3 on 2024-04-28 11:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="channel",
options={
"ordering": ("category", "name"),
"verbose_name": "channel",
"verbose_name_plural": "channels",
},
),
migrations.AddField(
model_name="channel",
name="category",
field=models.CharField(
choices=[
("general", "General channels"),
("tournament", "Tournament channels"),
("team", "Team channels"),
("private", "Private channels"),
],
default="general",
max_length=255,
verbose_name="category",
),
),
]

View File

@ -13,11 +13,24 @@ from tfjm.permissions import PermissionType
class Channel(models.Model): class Channel(models.Model):
class ChannelCategory(models.TextChoices):
GENERAL = 'general', _("General channels")
TOURNAMENT = 'tournament', _("Tournament channels")
TEAM = 'team', _("Team channels")
PRIVATE = 'private', _("Private channels")
name = models.CharField( name = models.CharField(
max_length=255, max_length=255,
verbose_name=_("name"), verbose_name=_("name"),
) )
category = models.CharField(
max_length=255,
verbose_name=_("category"),
choices=ChannelCategory,
default=ChannelCategory.GENERAL,
)
read_access = models.CharField( read_access = models.CharField(
max_length=16, max_length=16,
verbose_name=_("read permission"), verbose_name=_("read permission"),
@ -78,6 +91,13 @@ class Channel(models.Model):
"in addition to the permitted group of the channel."), "in addition to the permitted group of the channel."),
) )
def get_visible_name(self, user: User) -> str:
if self.private:
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)
return self.name
def __str__(self): def __str__(self):
return str(format_lazy(_("Channel {name}"), name=self.name)) return str(format_lazy(_("Channel {name}"), name=self.name))
@ -90,10 +110,10 @@ class Channel(models.Model):
return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS}) return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS})
qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED}) qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED})
registration = await Registration.objects.aget(user_id=user.id) registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id)
if registration.is_admin: if registration.is_admin:
return Channel.objects.all() return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all()
if registration.is_volunteer: if registration.is_volunteer:
registration = await VolunteerRegistration.objects \ registration = await VolunteerRegistration.objects \
@ -140,14 +160,14 @@ class Channel(models.Model):
qs |= Channel.objects.filter(Q(team=team), qs |= Channel.objects.filter(Q(team=team),
**{permission_type: PermissionType.TEAM_MEMBER}) **{permission_type: PermissionType.TEAM_MEMBER})
qs |= Channel.objects.filter(invited=user) qs |= Channel.objects.filter(invited=user).prefetch_related('invited')
return qs return qs
class Meta: class Meta:
verbose_name = _("channel") verbose_name = _("channel")
verbose_name_plural = _("channels") verbose_name_plural = _("channels")
ordering = ('name',) ordering = ('category', 'name',)
class Message(models.Model): class Message(models.Model):

100
chat/signals.py Normal file
View File

@ -0,0 +1,100 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from chat.models import Channel
from participation.models import Participation, Pool, Tournament
from tfjm.permissions import PermissionType
def create_tournament_channels(instance: Tournament, **_kwargs):
tournament = instance
Channel.objects.update_or_create(
name=f"{tournament.name} - Annonces",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_ORGANIZER,
tournament=tournament,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Général",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_MEMBER,
tournament=tournament,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Détente",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_MEMBER,
tournament=tournament,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Juré⋅es",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.JURY_MEMBER,
write_access=PermissionType.JURY_MEMBER,
tournament=tournament,
),
)
if tournament.remote:
Channel.objects.update_or_create(
name=f"{tournament.name} - Président⋅es de jury",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
tournament=tournament,
),
)
def create_pool_channels(instance: Pool, **_kwargs):
pool = instance
tournament = pool.tournament
if tournament.remote:
Channel.objects.update_or_create(
name=f"{tournament.name} - Poule {pool.short_name}",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.POOL_MEMBER,
write_access=PermissionType.POOL_MEMBER,
pool=pool,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.JURY_MEMBER,
write_access=PermissionType.JURY_MEMBER,
pool=pool,
),
)
def create_team_channel(instance: Participation, **_kwargs):
if instance.valid:
Channel.objects.update_or_create(
name=f"Équipe {instance.team.trigram}",
defaults=dict(
category=Channel.ChannelCategory.TEAM,
read_access=PermissionType.TEAM_MEMBER,
write_access=PermissionType.TEAM_MEMBER,
team=instance.team,
),
)

View File

@ -6,6 +6,7 @@
const MAX_MESSAGES = 50 const MAX_MESSAGES = 50
const channel_categories = ['general', 'tournament', 'team', 'private']
let channels = {} let channels = {}
let messages = {} let messages = {}
let selected_channel_id = null let selected_channel_id = null
@ -62,20 +63,27 @@ function sendMessage() {
function setChannels(new_channels) { function setChannels(new_channels) {
channels = {} channels = {}
let navTab = document.getElementById('nav-channels-tab') let categoryLists = {}
navTab.innerHTML = '' 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) { for (let channel of new_channels) {
channels[channel['id']] = channel channels[channel['id']] = channel
if (!messages[channel['id']]) if (!messages[channel['id']])
messages[channel['id']] = new Map() messages[channel['id']] = new Map()
let categoryList = categoryLists[channel['category']]
categoryList.parentElement.classList.remove('d-none')
let navItem = document.createElement('li') let navItem = document.createElement('li')
navItem.classList.add('list-group-item') navItem.classList.add('list-group-item')
navItem.id = `tab-channel-${channel['id']}` navItem.id = `tab-channel-${channel['id']}`
navItem.setAttribute('data-bs-dismiss', 'offcanvas') navItem.setAttribute('data-bs-dismiss', 'offcanvas')
navItem.onclick = () => selectChannel(channel['id']) navItem.onclick = () => selectChannel(channel['id'])
navTab.appendChild(navItem) categoryList.appendChild(navItem)
let channelButton = document.createElement('button') let channelButton = document.createElement('button')
channelButton.classList.add('nav-link') channelButton.classList.add('nav-link')
@ -87,8 +95,13 @@ function setChannels(new_channels) {
} }
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
if (window.location.hash) if (window.location.hash) {
selectChannel(window.location.hash.substring(9)) let channel_id = parseInt(window.location.hash.substring(9))
if (channels[channel_id])
selectChannel(channel_id)
else
selectChannel(Object.keys(channels)[0])
}
else else
selectChannel(Object.keys(channels)[0]) selectChannel(Object.keys(channels)[0])
} }
@ -109,6 +122,16 @@ function receiveMessage(message) {
showNotification(channels[message['channel_id']]['name'], `${message['author']} : ${message['content']}`) 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) { function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
'type': 'fetch_messages', 'type': 'fetch_messages',
@ -142,6 +165,22 @@ function receiveFetchedMessages(data) {
redrawMessages() redrawMessages()
} }
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() { function redrawMessages() {
let messageList = document.getElementById('message-list') let messageList = document.getElementById('message-list')
messageList.innerHTML = '' messageList.innerHTML = ''
@ -155,8 +194,12 @@ function redrawMessages() {
let newTimestamp = new Date(message['timestamp']) let newTimestamp = new Date(message['timestamp'])
if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) { if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) {
let messageContentDiv = document.createElement('div') let messageContentDiv = document.createElement('div')
messageContentDiv.innerText = message['content']
lastContentDiv.appendChild(messageContentDiv) lastContentDiv.appendChild(messageContentDiv)
let messageContentSpan = document.createElement('span')
messageContentSpan.innerText = message['content']
messageContentDiv.appendChild(messageContentSpan)
registerMessageContextMenu(message, messageContentSpan)
continue continue
} }
} }
@ -173,6 +216,8 @@ function redrawMessages() {
authorSpan.innerText = message['author'] authorSpan.innerText = message['author']
authorDiv.appendChild(authorSpan) authorDiv.appendChild(authorSpan)
registerSendPrivateMessageContextMenu(message, authorSpan)
let dateSpan = document.createElement('span') let dateSpan = document.createElement('span')
dateSpan.classList.add('text-muted', 'float-end') dateSpan.classList.add('text-muted', 'float-end')
dateSpan.innerText = new Date(message['timestamp']).toLocaleString() dateSpan.innerText = new Date(message['timestamp']).toLocaleString()
@ -182,8 +227,12 @@ function redrawMessages() {
messageElement.appendChild(contentDiv) messageElement.appendChild(contentDiv)
let messageContentDiv = document.createElement('div') let messageContentDiv = document.createElement('div')
messageContentDiv.innerText = message['content']
contentDiv.appendChild(messageContentDiv) contentDiv.appendChild(messageContentDiv)
let messageContentSpan = document.createElement('span')
messageContentSpan.innerText = message['content']
messageContentDiv.appendChild(messageContentSpan)
registerMessageContextMenu(message, messageContentSpan)
lastMessage = message lastMessage = message
lastContentDiv = contentDiv lastContentDiv = contentDiv
@ -196,6 +245,93 @@ function redrawMessages() {
fetchMoreButton.classList.remove('d-none') fetchMoreButton.classList.remove('d-none')
} }
function removeAllPopovers() {
for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) {
let instance = bootstrap.Popover.getInstance(popover)
if (instance)
instance.dispose()
}
}
function registerSendPrivateMessageContextMenu(message, element) {
element.addEventListener('contextmenu', (menu_event) => {
menu_event.preventDefault()
removeAllPopovers()
const popover = bootstrap.Popover.getOrCreateInstance(element, {
'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, element) {
element.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(element, {
'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 ?")) {
socket.send(JSON.stringify({
'type': 'delete_message',
'message_id': message['id'],
}))
}
})
}
})
}
function toggleFullscreen() { function toggleFullscreen() {
let chatContainer = document.getElementById('chat-container') let chatContainer = document.getElementById('chat-container')
if (!chatContainer.getAttribute('data-fullscreen')) { if (!chatContainer.getAttribute('data-fullscreen')) {
@ -211,6 +347,8 @@ function toggleFullscreen() {
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', removeAllPopovers)
/** /**
* Process the received data from the server. * Process the received data from the server.
* @param data The received message * @param data The received message
@ -223,9 +361,18 @@ document.addEventListener('DOMContentLoaded', () => {
case 'send_message': case 'send_message':
receiveMessage(data) receiveMessage(data)
break break
case 'edit_message':
editMessage(data)
break
case 'delete_message':
deleteMessage(data)
break
case 'fetch_messages': case 'fetch_messages':
receiveFetchedMessages(data) receiveFetchedMessages(data)
break break
case 'start_private_chat':
startPrivateChat(data)
break
default: default:
console.log(data) console.log(data)
console.error('Unknown message type:', data['type']) console.error('Unknown message type:', data['type'])
@ -238,6 +385,7 @@ document.addEventListener('DOMContentLoaded', () => {
socket = new WebSocket( socket = new WebSocket(
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/' (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/'
) )
let socketOpen = false
// Listen on websockets and process messages from the server // Listen on websockets and process messages from the server
socket.addEventListener('message', e => { socket.addEventListener('message', e => {
@ -250,10 +398,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Manage errors // Manage errors
socket.addEventListener('close', e => { socket.addEventListener('close', e => {
console.error('Chat socket closed unexpectedly, restarting…') console.error('Chat socket closed unexpectedly, restarting…')
setTimeout(() => setupSocket(2 * nextDelay), nextDelay) setTimeout(() => setupSocket(socketOpen ? 1000 : 2 * nextDelay), nextDelay)
}) })
socket.addEventListener('open', e => { socket.addEventListener('open', e => {
socketOpen = true
socket.send(JSON.stringify({ socket.send(JSON.stringify({
'type': 'fetch_channels', 'type': 'fetch_channels',
})) }))

View File

@ -3,13 +3,30 @@
<noscript> <noscript>
{% trans "JavaScript must be enabled on your browser to access chat." %} {% trans "JavaScript must be enabled on your browser to access chat." %}
</noscript> </noscript>
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasExampleLabel"> <div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle">
<div class="offcanvas-header"> <div class="offcanvas-header">
<h4 class="offcanvas-title" id="offcanvasExampleLabel">{% trans "Chat channels" %}</h4> <h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div> </div>
<div class="offcanvas-body"> <div class="offcanvas-body">
<ul class="list-group list-group-flush" id="nav-channels-tab"></ul> <ul class="list-group list-group-flush" id="nav-channels-tab">
<li class="list-group-item d-none">
<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">
<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">
<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">
<h4>{% trans "Private channels" %}</h4>
<ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul>
</li>
</ul>
</div> </div>
</div> </div>
@ -71,3 +88,8 @@
</form> </form>
</div> </div>
</div> </div>
<script>
const USER_ID = {{ request.user.id }}
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
</script>

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: TFJM\n" "Project-Id-Version: TFJM\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-28 11:56+0200\n" "POT-Creation-Date: 2024-04-28 13:08+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n" "Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -21,20 +21,40 @@ msgstr ""
msgid "API" msgid "API"
msgstr "API" msgstr "API"
#: chat/models.py:18 participation/models.py:35 participation/models.py:263 #: chat/models.py:17
msgid "General channels"
msgstr "Canaux généraux"
#: chat/models.py:18
msgid "Tournament channels"
msgstr "Canaux de tournois"
#: chat/models.py:19
msgid "Team channels"
msgstr "Canaux d'équipes"
#: chat/models.py:20
msgid "Private channels"
msgstr "Messages privés"
#: chat/models.py:24 participation/models.py:35 participation/models.py:263
#: participation/tables.py:18 participation/tables.py:34 #: participation/tables.py:18 participation/tables.py:34
msgid "name" msgid "name"
msgstr "nom" msgstr "nom"
#: chat/models.py:23 #: chat/models.py:29
msgid "category"
msgstr "catégorie"
#: chat/models.py:36
msgid "read permission" msgid "read permission"
msgstr "permission de lecture" msgstr "permission de lecture"
#: chat/models.py:29 #: chat/models.py:42
msgid "write permission" msgid "write permission"
msgstr "permission d'écriture" msgstr "permission d'écriture"
#: chat/models.py:39 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 #: draw/models.py:26 participation/admin.py:79 participation/admin.py:140
#: participation/admin.py:171 participation/models.py:693 #: participation/admin.py:171 participation/models.py:693
#: participation/models.py:717 participation/models.py:935 #: participation/models.py:717 participation/models.py:935
@ -43,7 +63,7 @@ msgstr "permission d'écriture"
msgid "tournament" msgid "tournament"
msgstr "tournoi" msgstr "tournoi"
#: chat/models.py:41 #: chat/models.py:54
msgid "" msgid ""
"For a permission that concerns a tournament, indicates what is the concerned " "For a permission that concerns a tournament, indicates what is the concerned "
"tournament." "tournament."
@ -51,21 +71,21 @@ msgstr ""
"Pour une permission qui concerne un tournoi, indique quel est le tournoi " "Pour une permission qui concerne un tournoi, indique quel est le tournoi "
"concerné." "concerné."
#: chat/models.py:50 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/admin.py:136 participation/admin.py:155
#: participation/models.py:1434 participation/models.py:1443 #: participation/models.py:1434 participation/models.py:1443
#: participation/tables.py:84 #: participation/tables.py:84
msgid "pool" msgid "pool"
msgstr "poule" msgstr "poule"
#: chat/models.py:52 #: chat/models.py:65
msgid "" msgid ""
"For a permission that concerns a pool, indicates what is the concerned pool." "For a permission that concerns a pool, indicates what is the concerned pool."
msgstr "" msgstr ""
"Pour une permission qui concerne une poule, indique quelle est la poule " "Pour une permission qui concerne une poule, indique quelle est la poule "
"concernée." "concernée."
#: chat/models.py:61 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/admin.py:167 participation/models.py:252
#: participation/models.py:708 #: participation/models.py:708
#: participation/templates/participation/tournament_harmonize.html:15 #: participation/templates/participation/tournament_harmonize.html:15
@ -75,18 +95,18 @@ msgstr ""
msgid "team" msgid "team"
msgstr "équipe" msgstr "équipe"
#: chat/models.py:63 #: chat/models.py:76
msgid "" msgid ""
"For a permission that concerns a team, indicates what is the concerned team." "For a permission that concerns a team, indicates what is the concerned team."
msgstr "" msgstr ""
"Pour une permission qui concerne une équipe, indique quelle est l'équipe " "Pour une permission qui concerne une équipe, indique quelle est l'équipe "
"concernée." "concernée."
#: chat/models.py:67 #: chat/models.py:80
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
#: chat/models.py:69 #: chat/models.py:82
msgid "" msgid ""
"If checked, only users who have been explicitly added to the channel will be " "If checked, only users who have been explicitly added to the channel will be "
"able to access it." "able to access it."
@ -94,11 +114,11 @@ msgstr ""
"Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement " "Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement "
"ajouté⋅es au canal pourront y accéder." "ajouté⋅es au canal pourront y accéder."
#: chat/models.py:74 #: chat/models.py:87
msgid "invited users" msgid "invited users"
msgstr "Utilisateur⋅rices invité" msgstr "Utilisateur⋅rices invité"
#: chat/models.py:77 #: chat/models.py:90
msgid "" msgid ""
"Extra users who have been invited to the channel, in addition to the " "Extra users who have been invited to the channel, in addition to the "
"permitted group of the channel." "permitted group of the channel."
@ -106,40 +126,40 @@ msgstr ""
"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du " "Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du "
"groupe autorisé du canal." "groupe autorisé du canal."
#: chat/models.py:82 #: chat/models.py:95
#, python-brace-format #, python-brace-format
msgid "Channel {name}" msgid "Channel {name}"
msgstr "Canal {name}" msgstr "Canal {name}"
#: chat/models.py:148 chat/models.py:157 #: chat/models.py:161 chat/models.py:170
msgid "channel" msgid "channel"
msgstr "canal" msgstr "canal"
#: chat/models.py:149 #: chat/models.py:162
msgid "channels" msgid "channels"
msgstr "canaux" msgstr "canaux"
#: chat/models.py:163 #: chat/models.py:176
msgid "author" msgid "author"
msgstr "auteur⋅rice" msgstr "auteur⋅rice"
#: chat/models.py:170 #: chat/models.py:183
msgid "created at" msgid "created at"
msgstr "créé le" msgstr "créé le"
#: chat/models.py:175 #: chat/models.py:188
msgid "updated at" msgid "updated at"
msgstr "modifié le" msgstr "modifié le"
#: chat/models.py:180 #: chat/models.py:193
msgid "content" msgid "content"
msgstr "contenu" msgstr "contenu"
#: chat/models.py:243 #: chat/models.py:256
msgid "message" msgid "message"
msgstr "message" msgstr "message"
#: chat/models.py:244 #: chat/models.py:257
msgid "messages" msgid "messages"
msgstr "messages" msgstr "messages"
@ -171,7 +191,7 @@ msgstr "Déconnexion"
msgid "Install app on home screen" msgid "Install app on home screen"
msgstr "Installer l'application sur l'écran d'accueil" msgstr "Installer l'application sur l'écran d'accueil"
#: chat/templates/chat/content.html:54 #: chat/templates/chat/content.html:55
msgid "Fetch previous messages…" msgid "Fetch previous messages…"
msgstr "Récupérer les messages précédents…" msgstr "Récupérer les messages précédents…"

View File

@ -27,7 +27,7 @@ SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False CSRF_COOKIE_SECURE = False
CSRF_COOKIE_HTTPONLY = False CSRF_COOKIE_HTTPONLY = False
X_FRAME_OPTIONS = 'DENY' X_FRAME_OPTIONS = 'DENY'
SESSION_COOKIE_AGE = 60 * 60 * 3 SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 * 2 # 2 weeks
CHANNEL_LAYERS = { CHANNEL_LAYERS = {
"default": { "default": {

View File

@ -13,7 +13,7 @@ deps = coverage
commands = commands =
python manage.py compilemessages -i .tox -i venv python manage.py compilemessages -i .tox -i venv
coverage run --source=api,draw,logs,participation,registration,tfjm ./manage.py test api/ draw/ logs/ participation/ registration/ tfjm/ coverage run --source=api,draw,logs,participation,registration,tfjm ./manage.py test api/ chat/ draw/ logs/ participation/ registration/ tfjm/
coverage report -m coverage report -m
[testenv:linters] [testenv:linters]
@ -26,7 +26,7 @@ deps =
pep8-naming pep8-naming
pyflakes pyflakes
commands = commands =
flake8 api/ draw/ logs/ participation/ registration/ tfjm/ flake8 api/ chat/ draw/ logs/ participation/ registration/ tfjm/
[flake8] [flake8]
exclude = exclude =