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

Compare commits

..

5 Commits

Author SHA1 Message Date
Emmy D'Anello
f817c71043
Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:03:25 +02:00
Emmy D'Anello
9aa002b359
Store last visited channel in local storage
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 23:56:41 +02:00
Emmy D'Anello
ed6ab57a9a
Add sort by unread messages option
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 23:54:00 +02:00
Emmy D'Anello
519688d997
Store what messages are read
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 23:25:15 +02:00
Emmy D'Anello
d19eb3d3fd
Improve context menus
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-28 20:25:00 +02:00
7 changed files with 262 additions and 79 deletions

View File

@ -19,4 +19,4 @@ class MessageAdmin(admin.ModelAdmin):
list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',) list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',)
list_filter = ('channel', 'created_at', 'updated_at',) list_filter = ('channel', 'created_at', 'updated_at',)
search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',) search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',)
autocomplete_fields = ('channel', 'author',) autocomplete_fields = ('channel', 'author', 'users_read',)

View File

@ -3,6 +3,7 @@
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 django.db.models import Count, Exists, OuterRef, Q
from registration.models import Registration from registration.models import Registration
from .models import Channel, Message from .models import Channel, Message
@ -34,8 +35,10 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
# Accept the connection # Accept the connection
await self.accept() await self.accept()
channels = await Channel.get_accessible_channels(user, 'read') self.read_channels = await Channel.get_accessible_channels(user, 'read')
async for channel in channels.all(): 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"chat-{channel.id}", self.channel_name)
await self.channel_layer.group_add(f"user-{user.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 # User is not authenticated
return return
channels = await Channel.get_accessible_channels(self.scope['user'], 'read') async for channel in self.read_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) 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) await self.delete_message(**content)
case 'fetch_messages': case 'fetch_messages':
await self.fetch_messages(**content) await self.fetch_messages(**content)
case 'mark_read':
await self.mark_read(**content)
case 'start_private_chat': case 'start_private_chat':
await self.start_private_chat(**content) await self.start_private_chat(**content)
case unknown: case unknown:
@ -77,8 +81,6 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
async def fetch_channels(self) -> None: async def fetch_channels(self) -> None:
user = self.scope['user'] user = self.scope['user']
read_channels = await Channel.get_accessible_channels(user, 'read')
write_channels = await Channel.get_accessible_channels(user, 'write')
message = { message = {
'type': 'fetch_channels', 'type': 'fetch_channels',
'channels': [ 'channels': [
@ -87,9 +89,11 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
'name': channel.get_visible_name(user), 'name': channel.get_visible_name(user),
'category': channel.category, 'category': channel.category,
'read_access': True, '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) await self.send_json(message)
@ -98,8 +102,7 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
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=channel_id) .aget(id=channel_id)
write_channels = await Channel.get_accessible_channels(user, 'write') if not await self.write_channels.acontains(channel):
if not await write_channels.acontains(channel):
return return
message = await Message.objects.acreate( 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: 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') if not await self.read_channels.acontains(channel):
if not await read_channels.acontains(channel):
return return
limit = min(limit, 200) # Fetch only maximum 200 messages at the time 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({ await self.send_json({
'type': 'fetch_messages', 'type': 'fetch_messages',
'channel_id': channel_id, 'channel_id': channel_id,
@ -167,11 +173,27 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
'author_id': message.author_id, 'author_id': message.author_id,
'author': await message.aget_author_name(), 'author': await message.aget_author_name(),
'content': message.content, 'content': message.content,
'read': message.read,
} }
async for message in messages 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: async def start_private_chat(self, user_id: int, **kwargs) -> None:
user = self.scope['user'] user = self.scope['user']
other_user = await User.objects.aget(id=user_id) other_user = await User.objects.aget(id=user_id)
@ -183,7 +205,6 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
private=True, private=True,
) )
await channel.invited.aset([user, other_user]) await channel.invited.aset([user, other_user])
await channel.asave()
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)

View 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",
),
),
]

View File

@ -200,6 +200,14 @@ class Message(models.Model):
verbose_name=_("content"), 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): def get_author_name(self):
registration = self.author.registration registration = self.author.registration

View File

@ -33,8 +33,7 @@ function selectChannel(channel_id) {
} }
selected_channel_id = channel_id selected_channel_id = channel_id
localStorage.setItem('chat.last-channel-id', channel_id)
window.history.replaceState({}, null, `#channel-${channel['id']}`)
let channelTitle = document.getElementById('channel-title') let channelTitle = document.getElementById('channel-title')
channelTitle.innerText = channel['name'] channelTitle.innerText = channel['name']
@ -70,43 +69,53 @@ function setChannels(new_channels) {
categoryLists[category].parentElement.classList.add('d-none') categoryLists[category].parentElement.classList.add('d-none')
} }
for (let channel of new_channels) { for (let channel of new_channels)
channels[channel['id']] = channel addChannel(channel, categoryLists)
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'])
}
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
if (window.location.hash) { let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id'))
let channel_id = parseInt(window.location.hash.substring(9)) if (last_channel_id && channels[last_channel_id])
if (channels[channel_id]) selectChannel(last_channel_id)
selectChannel(channel_id)
else
selectChannel(Object.keys(channels)[0])
}
else else
selectChannel(Object.keys(channels)[0]) 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) { function receiveMessage(message) {
let scrollableContent = document.getElementById('chat-messages') let scrollableContent = document.getElementById('chat-messages')
let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1 let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1
@ -165,6 +174,36 @@ function receiveFetchedMessages(data) {
redrawMessages() 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
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')
if (sortByUnread)
document.getElementById(`tab-channel-${channel['id']}`).style.order = `${-unreadMessagesChannel}`
}
}
function startPrivateChat(data) { function startPrivateChat(data) {
let channel = data['channel'] let channel = data['channel']
if (!channel) { if (!channel) {
@ -194,12 +233,14 @@ 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.classList.add('message')
messageContentDiv.setAttribute('data-message-id', message['id'])
lastContentDiv.appendChild(messageContentDiv) lastContentDiv.appendChild(messageContentDiv)
let messageContentSpan = document.createElement('span') let messageContentSpan = document.createElement('span')
messageContentSpan.innerText = message['content'] messageContentSpan.innerText = message['content']
messageContentDiv.appendChild(messageContentSpan) messageContentDiv.appendChild(messageContentSpan)
registerMessageContextMenu(message, messageContentSpan) registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
continue continue
} }
} }
@ -216,7 +257,7 @@ function redrawMessages() {
authorSpan.innerText = message['author'] authorSpan.innerText = message['author']
authorDiv.appendChild(authorSpan) authorDiv.appendChild(authorSpan)
registerSendPrivateMessageContextMenu(message, authorSpan) registerSendPrivateMessageContextMenu(message, authorDiv, 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')
@ -227,12 +268,14 @@ function redrawMessages() {
messageElement.appendChild(contentDiv) messageElement.appendChild(contentDiv)
let messageContentDiv = document.createElement('div') let messageContentDiv = document.createElement('div')
messageContentDiv.classList.add('message')
messageContentDiv.setAttribute('data-message-id', message['id'])
contentDiv.appendChild(messageContentDiv) contentDiv.appendChild(messageContentDiv)
let messageContentSpan = document.createElement('span') let messageContentSpan = document.createElement('span')
messageContentSpan.innerText = message['content'] messageContentSpan.innerText = message['content']
messageContentDiv.appendChild(messageContentSpan) messageContentDiv.appendChild(messageContentSpan)
registerMessageContextMenu(message, messageContentSpan) registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
lastMessage = message lastMessage = message
lastContentDiv = contentDiv lastContentDiv = contentDiv
@ -243,6 +286,8 @@ function redrawMessages() {
fetchMoreButton.classList.add('d-none') fetchMoreButton.classList.add('d-none')
else else
fetchMoreButton.classList.remove('d-none') fetchMoreButton.classList.remove('d-none')
messageList.dispatchEvent(new CustomEvent('updatemessages'))
} }
function removeAllPopovers() { function removeAllPopovers() {
@ -253,11 +298,11 @@ function removeAllPopovers() {
} }
} }
function registerSendPrivateMessageContextMenu(message, element) { function registerSendPrivateMessageContextMenu(message, div, span) {
element.addEventListener('contextmenu', (menu_event) => { div.addEventListener('contextmenu', (menu_event) => {
menu_event.preventDefault() menu_event.preventDefault()
removeAllPopovers() removeAllPopovers()
const popover = bootstrap.Popover.getOrCreateInstance(element, { const popover = bootstrap.Popover.getOrCreateInstance(span, {
'title': message['author'], 'title': message['author'],
'content': `<a id="send-private-message-link-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`, 'content': `<a id="send-private-message-link-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`,
'html': true, 'html': true,
@ -275,8 +320,8 @@ function registerSendPrivateMessageContextMenu(message, element) {
}) })
} }
function registerMessageContextMenu(message, element) { function registerMessageContextMenu(message, div, span) {
element.addEventListener('contextmenu', (menu_event) => { div.addEventListener('contextmenu', (menu_event) => {
menu_event.preventDefault() menu_event.preventDefault()
removeAllPopovers() removeAllPopovers()
let content = `<a id="send-private-message-link-msg-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>` let content = `<a id="send-private-message-link-msg-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`
@ -288,7 +333,7 @@ function registerMessageContextMenu(message, element) {
content += `<a id="delete-message-${message['id']}" class="nav-link" href="#" tabindex="0">Supprimer</a>` content += `<a id="delete-message-${message['id']}" class="nav-link" href="#" tabindex="0">Supprimer</a>`
} }
const popover = bootstrap.Popover.getOrCreateInstance(element, { const popover = bootstrap.Popover.getOrCreateInstance(span, {
'content': content, 'content': content,
'html': true, 'html': true,
'placement': 'bottom', 'placement': 'bottom',
@ -321,7 +366,7 @@ function registerMessageContextMenu(message, element) {
document.getElementById('delete-message-' + message['id']).addEventListener('click', event => { document.getElementById('delete-message-' + message['id']).addEventListener('click', event => {
event.preventDefault() event.preventDefault()
popover.dispose() popover.dispose()
if (confirm("Supprimer le message ?")) { if (confirm(`Supprimer le message ?\n${message['content']}`)) {
socket.send(JSON.stringify({ socket.send(JSON.stringify({
'type': 'delete_message', 'type': 'delete_message',
'message_id': message['id'], 'message_id': message['id'],
@ -337,18 +382,36 @@ function toggleFullscreen() {
if (!chatContainer.getAttribute('data-fullscreen')) { if (!chatContainer.getAttribute('data-fullscreen')) {
chatContainer.setAttribute('data-fullscreen', 'true') chatContainer.setAttribute('data-fullscreen', 'true')
chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
window.history.replaceState({}, null, `?fullscreen=1#channel-${selected_channel_id}`) window.history.replaceState({}, null, `?fullscreen=1`)
} }
else { else {
chatContainer.removeAttribute('data-fullscreen') chatContainer.removeAttribute('data-fullscreen')
chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
window.history.replaceState({}, null, `?fullscreen=0#channel-${selected_channel_id}`) window.history.replaceState({}, null, `?fullscreen=0`)
} }
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', removeAllPopovers) 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. * Process the received data from the server.
* @param data The received message * @param data The received message
@ -370,6 +433,9 @@ document.addEventListener('DOMContentLoaded', () => {
case 'fetch_messages': case 'fetch_messages':
receiveFetchedMessages(data) receiveFetchedMessages(data)
break break
case 'mark_read':
markMessageAsRead(data)
break
case 'start_private_chat': case 'start_private_chat':
startPrivateChat(data) startPrivateChat(data)
break break
@ -435,6 +501,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() { function setupPWAPrompt() {
let deferredPrompt = null let deferredPrompt = null
@ -460,5 +571,6 @@ document.addEventListener('DOMContentLoaded', () => {
setupSocket() setupSocket()
setupSwipeOffscreen() setupSwipeOffscreen()
setupReadTracker()
setupPWAPrompt() setupPWAPrompt()
}) })

View File

@ -9,6 +9,10 @@
<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">
<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"> <ul class="list-group list-group-flush" id="nav-channels-tab">
<li class="list-group-item d-none"> <li class="list-group-item d-none">
<h4>{% trans "General channels" %}</h4> <h4>{% trans "General channels" %}</h4>

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 13:08+0200\n" "POT-Creation-Date: 2024-04-28 23:37+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,19 +21,19 @@ msgstr ""
msgid "API" msgid "API"
msgstr "API" msgstr "API"
#: chat/models.py:17 #: chat/models.py:17 chat/templates/chat/content.html:18
msgid "General channels" msgid "General channels"
msgstr "Canaux généraux" msgstr "Canaux généraux"
#: chat/models.py:18 #: chat/models.py:18 chat/templates/chat/content.html:22
msgid "Tournament channels" msgid "Tournament channels"
msgstr "Canaux de tournois" msgstr "Canaux de tournois"
#: chat/models.py:19 #: chat/models.py:19 chat/templates/chat/content.html:26
msgid "Team channels" msgid "Team channels"
msgstr "Canaux d'équipes" msgstr "Canaux d'équipes"
#: chat/models.py:20 #: chat/models.py:20 chat/templates/chat/content.html:30
msgid "Private channels" msgid "Private channels"
msgstr "Messages privés" msgstr "Messages privés"
@ -126,40 +126,48 @@ 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:95 #: chat/models.py:102
#, python-brace-format #, python-brace-format
msgid "Channel {name}" msgid "Channel {name}"
msgstr "Canal {name}" msgstr "Canal {name}"
#: chat/models.py:161 chat/models.py:170 #: chat/models.py:168 chat/models.py:177
msgid "channel" msgid "channel"
msgstr "canal" msgstr "canal"
#: chat/models.py:162 #: chat/models.py:169
msgid "channels" msgid "channels"
msgstr "canaux" msgstr "canaux"
#: chat/models.py:176 #: chat/models.py:183
msgid "author" msgid "author"
msgstr "auteur⋅rice" msgstr "auteur⋅rice"
#: chat/models.py:183 #: chat/models.py:190
msgid "created at" msgid "created at"
msgstr "créé le" msgstr "créé le"
#: chat/models.py:188 #: chat/models.py:195
msgid "updated at" msgid "updated at"
msgstr "modifié le" msgstr "modifié le"
#: chat/models.py:193 #: chat/models.py:200
msgid "content" msgid "content"
msgstr "contenu" 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" msgid "message"
msgstr "message" msgstr "message"
#: chat/models.py:257 #: chat/models.py:272
msgid "messages" msgid "messages"
msgstr "messages" msgstr "messages"
@ -171,7 +179,11 @@ msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat
msgid "Chat channels" msgid "Chat channels"
msgstr "Canaux de chat" 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:38
msgid "" msgid ""
"You can install a shortcut to the chat on your home screen using the " "You can install a shortcut to the chat on your home screen using the "
"download button on the header." "download button on the header."
@ -179,23 +191,23 @@ msgstr ""
"Vous pouvez installer un raccourci vers le chat sur votre écran d'accueil en " "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." "utilisant le bouton de téléchargement dans l'en-tête."
#: chat/templates/chat/content.html:35 #: chat/templates/chat/content.html:56
msgid "Toggle fullscreen mode" msgid "Toggle fullscreen mode"
msgstr "Inverse le mode plein écran" msgstr "Inverse le mode plein écran"
#: chat/templates/chat/content.html:39 tfjm/templates/navbar.html:117 #: chat/templates/chat/content.html:60 tfjm/templates/navbar.html:117
msgid "Log out" msgid "Log out"
msgstr "Déconnexion" msgstr "Déconnexion"
#: chat/templates/chat/content.html:43 #: chat/templates/chat/content.html:64
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:55 #: chat/templates/chat/content.html:76
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…"
#: chat/templates/chat/content.html:66 #: chat/templates/chat/content.html:87
msgid "Send message…" msgid "Send message…"
msgstr "Envoyer un message…" msgstr "Envoyer un message…"