mirror of
				https://gitlab.com/animath/si/plateforme.git
				synced 2025-11-04 09:42:10 +01:00 
			
		
		
		
	Store what messages are read
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
This commit is contained in:
		@@ -19,4 +19,4 @@ class MessageAdmin(admin.ModelAdmin):
 | 
			
		||||
    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',)
 | 
			
		||||
    autocomplete_fields = ('channel', 'author',)
 | 
			
		||||
    autocomplete_fields = ('channel', 'author', 'users_read',)
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@
 | 
			
		||||
 | 
			
		||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
from django.db.models import Exists, OuterRef, Count, Q
 | 
			
		||||
from registration.models import Registration
 | 
			
		||||
 | 
			
		||||
from .models import Channel, Message
 | 
			
		||||
@@ -34,8 +35,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
        # Accept the connection
 | 
			
		||||
        await self.accept()
 | 
			
		||||
 | 
			
		||||
        channels = await Channel.get_accessible_channels(user, 'read')
 | 
			
		||||
        async for channel in channels.all():
 | 
			
		||||
        self.read_channels = await Channel.get_accessible_channels(user, 'read')
 | 
			
		||||
        self.write_channels = await Channel.get_accessible_channels(user, 'write')
 | 
			
		||||
 | 
			
		||||
        async for channel in self.read_channels.all():
 | 
			
		||||
            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)
 | 
			
		||||
 | 
			
		||||
@@ -48,8 +51,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
            # User is not authenticated
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        channels = await Channel.get_accessible_channels(self.scope['user'], 'read')
 | 
			
		||||
        async for channel in channels.all():
 | 
			
		||||
        async for channel in self.read_channels.all():
 | 
			
		||||
            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)
 | 
			
		||||
 | 
			
		||||
@@ -69,6 +71,8 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
                await self.delete_message(**content)
 | 
			
		||||
            case 'fetch_messages':
 | 
			
		||||
                await self.fetch_messages(**content)
 | 
			
		||||
            case 'mark_read':
 | 
			
		||||
                await self.mark_read(**content)
 | 
			
		||||
            case 'start_private_chat':
 | 
			
		||||
                await self.start_private_chat(**content)
 | 
			
		||||
            case unknown:
 | 
			
		||||
@@ -77,8 +81,6 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
    async def fetch_channels(self) -> None:
 | 
			
		||||
        user = self.scope['user']
 | 
			
		||||
 | 
			
		||||
        read_channels = await Channel.get_accessible_channels(user, 'read')
 | 
			
		||||
        write_channels = await Channel.get_accessible_channels(user, 'write')
 | 
			
		||||
        message = {
 | 
			
		||||
            'type': 'fetch_channels',
 | 
			
		||||
            'channels': [
 | 
			
		||||
@@ -87,9 +89,11 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
                    'name': channel.get_visible_name(user),
 | 
			
		||||
                    'category': channel.category,
 | 
			
		||||
                    'read_access': True,
 | 
			
		||||
                    'write_access': await write_channels.acontains(channel),
 | 
			
		||||
                    'write_access': await self.write_channels.acontains(channel),
 | 
			
		||||
                    'unread_messages': channel.unread_messages,
 | 
			
		||||
                }
 | 
			
		||||
                async for channel in read_channels.prefetch_related('invited').all()
 | 
			
		||||
                async for channel in self.read_channels.prefetch_related('invited') \
 | 
			
		||||
                    .annotate(unread_messages=Count('messages', filter=~Q(messages__users_read=user))).all()
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
        await self.send_json(message)
 | 
			
		||||
@@ -98,8 +102,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
        user = self.scope['user']
 | 
			
		||||
        channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
 | 
			
		||||
            .aget(id=channel_id)
 | 
			
		||||
        write_channels = await Channel.get_accessible_channels(user, 'write')
 | 
			
		||||
        if not await write_channels.acontains(channel):
 | 
			
		||||
        if not await self.write_channels.acontains(channel):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        message = await Message.objects.acreate(
 | 
			
		||||
@@ -150,13 +153,16 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
 | 
			
		||||
    async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
 | 
			
		||||
        channel = await Channel.objects.aget(id=channel_id)
 | 
			
		||||
        read_channels = await Channel.get_accessible_channels(self.scope['user'], 'read')
 | 
			
		||||
        if not await read_channels.acontains(channel):
 | 
			
		||||
        if not await self.read_channels.acontains(channel):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        limit = min(limit, 200)  # Fetch only maximum 200 messages at the time
 | 
			
		||||
 | 
			
		||||
        messages = Message.objects.filter(channel=channel).order_by('-created_at')[offset:offset + limit].all()
 | 
			
		||||
        messages = Message.objects \
 | 
			
		||||
                       .filter(channel=channel) \
 | 
			
		||||
                       .annotate(read=Exists(User.objects.filter(pk=self.scope['user'].pk) \
 | 
			
		||||
                       .filter(pk=OuterRef('users_read')))) \
 | 
			
		||||
                       .order_by('-created_at')[offset:offset + limit].all()
 | 
			
		||||
        await self.send_json({
 | 
			
		||||
            'type': 'fetch_messages',
 | 
			
		||||
            'channel_id': channel_id,
 | 
			
		||||
@@ -167,11 +173,27 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
                    'author_id': message.author_id,
 | 
			
		||||
                    'author': await message.aget_author_name(),
 | 
			
		||||
                    'content': message.content,
 | 
			
		||||
                    'read': message.read,
 | 
			
		||||
                }
 | 
			
		||||
                async for message in messages
 | 
			
		||||
            ]))
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
    async def mark_read(self, message_ids: list[int], **_kwargs) -> None:
 | 
			
		||||
        messages = Message.objects.filter(id__in=message_ids)
 | 
			
		||||
        async for message in messages.all():
 | 
			
		||||
            await message.users_read.aadd(self.scope['user'])
 | 
			
		||||
 | 
			
		||||
        unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
 | 
			
		||||
            .annotate(unread_messages=Count('channel_id'))
 | 
			
		||||
 | 
			
		||||
        await self.send_json({
 | 
			
		||||
            'type': 'mark_read',
 | 
			
		||||
            'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()],
 | 
			
		||||
            'unread_messages': {group['channel_id']: group['unread_messages']
 | 
			
		||||
                                async for group in unread_messages_by_channel.all()},
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
    async def start_private_chat(self, user_id: int, **kwargs) -> None:
 | 
			
		||||
        user = self.scope['user']
 | 
			
		||||
        other_user = await User.objects.aget(id=user_id)
 | 
			
		||||
@@ -183,7 +205,6 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
 | 
			
		||||
                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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								chat/migrations/0003_message_users_read.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								chat/migrations/0003_message_users_read.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
# Generated by Django 5.0.3 on 2024-04-28 18:52
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("chat", "0002_alter_channel_options_channel_category"),
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="message",
 | 
			
		||||
            name="users_read",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                help_text="Users who have read the message.",
 | 
			
		||||
                related_name="+",
 | 
			
		||||
                to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                verbose_name="users read",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -200,6 +200,14 @@ class Message(models.Model):
 | 
			
		||||
        verbose_name=_("content"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    users_read = models.ManyToManyField(
 | 
			
		||||
        'auth.User',
 | 
			
		||||
        verbose_name=_("users read"),
 | 
			
		||||
        related_name='+',
 | 
			
		||||
        blank=True,
 | 
			
		||||
        help_text=_("Users who have read the message."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def get_author_name(self):
 | 
			
		||||
        registration = self.author.registration
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -70,29 +70,8 @@ function setChannels(new_channels) {
 | 
			
		||||
        categoryLists[category].parentElement.classList.add('d-none')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (let channel of new_channels) {
 | 
			
		||||
        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')
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        fetchMessages(channel['id'])
 | 
			
		||||
    }
 | 
			
		||||
    for (let channel of new_channels)
 | 
			
		||||
        addChannel(channel, categoryLists)
 | 
			
		||||
 | 
			
		||||
    if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
 | 
			
		||||
        if (window.location.hash) {
 | 
			
		||||
@@ -107,6 +86,38 @@ function setChannels(new_channels) {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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')
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    fetchMessages(channel['id'])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function receiveMessage(message) {
 | 
			
		||||
    let scrollableContent = document.getElementById('chat-messages')
 | 
			
		||||
    let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1
 | 
			
		||||
@@ -165,6 +176,32 @@ function receiveFetchedMessages(data) {
 | 
			
		||||
    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) {
 | 
			
		||||
    console.log(unreadMessages)
 | 
			
		||||
    for (let channel of Object.values(channels)) {
 | 
			
		||||
        let unreadMessagesChannel = unreadMessages[channel['id']] || 0
 | 
			
		||||
        console.log(channel, unreadMessagesChannel)
 | 
			
		||||
        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')
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function startPrivateChat(data) {
 | 
			
		||||
    let channel = data['channel']
 | 
			
		||||
    if (!channel) {
 | 
			
		||||
@@ -194,6 +231,8 @@ function redrawMessages() {
 | 
			
		||||
            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.innerText = message['content']
 | 
			
		||||
@@ -227,6 +266,8 @@ function redrawMessages() {
 | 
			
		||||
        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.innerText = message['content']
 | 
			
		||||
@@ -243,6 +284,8 @@ function redrawMessages() {
 | 
			
		||||
        fetchMoreButton.classList.add('d-none')
 | 
			
		||||
    else
 | 
			
		||||
        fetchMoreButton.classList.remove('d-none')
 | 
			
		||||
 | 
			
		||||
    messageList.dispatchEvent(new CustomEvent('updatemessages'))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function removeAllPopovers() {
 | 
			
		||||
@@ -370,6 +413,9 @@ document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
            case 'fetch_messages':
 | 
			
		||||
                receiveFetchedMessages(data)
 | 
			
		||||
                break
 | 
			
		||||
            case 'mark_read':
 | 
			
		||||
                markMessageAsRead(data)
 | 
			
		||||
                break
 | 
			
		||||
            case 'start_private_chat':
 | 
			
		||||
                startPrivateChat(data)
 | 
			
		||||
                break
 | 
			
		||||
@@ -435,6 +481,51 @@ document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
@@ -460,5 +551,6 @@ document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
 | 
			
		||||
    setupSocket()
 | 
			
		||||
    setupSwipeOffscreen()
 | 
			
		||||
    setupReadTracker()
 | 
			
		||||
    setupPWAPrompt()
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: TFJM\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: \n"
 | 
			
		||||
"POT-Creation-Date: 2024-04-28 13:08+0200\n"
 | 
			
		||||
"POT-Creation-Date: 2024-04-28 23:24+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,19 +21,19 @@ msgstr ""
 | 
			
		||||
msgid "API"
 | 
			
		||||
msgstr "API"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:17
 | 
			
		||||
#: chat/models.py:17 chat/templates/chat/content.html:14
 | 
			
		||||
msgid "General channels"
 | 
			
		||||
msgstr "Canaux généraux"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:18
 | 
			
		||||
#: chat/models.py:18 chat/templates/chat/content.html:18
 | 
			
		||||
msgid "Tournament channels"
 | 
			
		||||
msgstr "Canaux de tournois"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:19
 | 
			
		||||
#: chat/models.py:19 chat/templates/chat/content.html:22
 | 
			
		||||
msgid "Team channels"
 | 
			
		||||
msgstr "Canaux d'équipes"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:20
 | 
			
		||||
#: chat/models.py:20 chat/templates/chat/content.html:26
 | 
			
		||||
msgid "Private channels"
 | 
			
		||||
msgstr "Messages privés"
 | 
			
		||||
 | 
			
		||||
@@ -126,40 +126,48 @@ msgstr ""
 | 
			
		||||
"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du "
 | 
			
		||||
"groupe autorisé du canal."
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:95
 | 
			
		||||
#: chat/models.py:102
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Channel {name}"
 | 
			
		||||
msgstr "Canal {name}"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:161 chat/models.py:170
 | 
			
		||||
#: chat/models.py:168 chat/models.py:177
 | 
			
		||||
msgid "channel"
 | 
			
		||||
msgstr "canal"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:162
 | 
			
		||||
#: chat/models.py:169
 | 
			
		||||
msgid "channels"
 | 
			
		||||
msgstr "canaux"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:176
 | 
			
		||||
#: chat/models.py:183
 | 
			
		||||
msgid "author"
 | 
			
		||||
msgstr "auteur⋅rice"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:183
 | 
			
		||||
#: chat/models.py:190
 | 
			
		||||
msgid "created at"
 | 
			
		||||
msgstr "créé le"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:188
 | 
			
		||||
#: chat/models.py:195
 | 
			
		||||
msgid "updated at"
 | 
			
		||||
msgstr "modifié le"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:193
 | 
			
		||||
#: chat/models.py:200
 | 
			
		||||
msgid "content"
 | 
			
		||||
msgstr "contenu"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:256
 | 
			
		||||
#: chat/models.py:205
 | 
			
		||||
msgid "users read"
 | 
			
		||||
msgstr "utilisateur⋅rices ayant lu"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:208
 | 
			
		||||
msgid "Users who have read the message."
 | 
			
		||||
msgstr "Utilisateur⋅rices qui ont lu le message."
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:271
 | 
			
		||||
msgid "message"
 | 
			
		||||
msgstr "message"
 | 
			
		||||
 | 
			
		||||
#: chat/models.py:257
 | 
			
		||||
#: chat/models.py:272
 | 
			
		||||
msgid "messages"
 | 
			
		||||
msgstr "messages"
 | 
			
		||||
 | 
			
		||||
@@ -171,7 +179,7 @@ msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat
 | 
			
		||||
msgid "Chat channels"
 | 
			
		||||
msgstr "Canaux de chat"
 | 
			
		||||
 | 
			
		||||
#: chat/templates/chat/content.html:17
 | 
			
		||||
#: chat/templates/chat/content.html:34
 | 
			
		||||
msgid ""
 | 
			
		||||
"You can install a shortcut to the chat on your home screen using the "
 | 
			
		||||
"download button on the header."
 | 
			
		||||
@@ -179,23 +187,23 @@ 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:35
 | 
			
		||||
#: chat/templates/chat/content.html:52
 | 
			
		||||
msgid "Toggle fullscreen mode"
 | 
			
		||||
msgstr "Inverse le mode plein écran"
 | 
			
		||||
 | 
			
		||||
#: chat/templates/chat/content.html:39 tfjm/templates/navbar.html:117
 | 
			
		||||
#: chat/templates/chat/content.html:56 tfjm/templates/navbar.html:117
 | 
			
		||||
msgid "Log out"
 | 
			
		||||
msgstr "Déconnexion"
 | 
			
		||||
 | 
			
		||||
#: chat/templates/chat/content.html:43
 | 
			
		||||
#: chat/templates/chat/content.html:60
 | 
			
		||||
msgid "Install app on home screen"
 | 
			
		||||
msgstr "Installer l'application sur l'écran d'accueil"
 | 
			
		||||
 | 
			
		||||
#: chat/templates/chat/content.html:55
 | 
			
		||||
#: chat/templates/chat/content.html:72
 | 
			
		||||
msgid "Fetch previous messages…"
 | 
			
		||||
msgstr "Récupérer les messages précédents…"
 | 
			
		||||
 | 
			
		||||
#: chat/templates/chat/content.html:66
 | 
			
		||||
#: chat/templates/chat/content.html:83
 | 
			
		||||
msgid "Send message…"
 | 
			
		||||
msgstr "Envoyer un message…"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user