diff --git a/chat/admin.py b/chat/admin.py index a94de30..757dcac 100644 --- a/chat/admin.py +++ b/chat/admin.py @@ -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',) diff --git a/chat/consumers.py b/chat/consumers.py index c8d2ce5..4812af7 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -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) diff --git a/chat/migrations/0003_message_users_read.py b/chat/migrations/0003_message_users_read.py new file mode 100644 index 0000000..16f9434 --- /dev/null +++ b/chat/migrations/0003_message_users_read.py @@ -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", + ), + ), + ] diff --git a/chat/models.py b/chat/models.py index 706060d..833890f 100644 --- a/chat/models.py +++ b/chat/models.py @@ -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 diff --git a/chat/static/chat.js b/chat/static/chat.js index 95a63a4..0962f34 100644 --- a/chat/static/chat.js +++ b/chat/static/chat.js @@ -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() }) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index dd23b19..69bd864 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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 \n" "Language-Team: LANGUAGE \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…"