(async () => { // Vérification de la permission pour envoyer des notifications // C'est utile pour prévenir les utilisateur⋅rices de l'arrivée de nouveaux messages les mentionnant await Notification.requestPermission() })() const MAX_MESSAGES = 50 // Nombre maximal de messages à charger à la fois const channel_categories = ['general', 'tournament', 'team', 'private'] // Liste des catégories de canaux let channels = {} // Liste des canaux disponibles let messages = {} // Liste des messages reçus par canal let selected_channel_id = null // Canal courant /** * Affiche une nouvelle notification avec le titre donné et le contenu donné. * @param title Le titre de la notification * @param body Le contenu de la notification * @param timeout La durée (en millisecondes) après laquelle la notification se ferme automatiquement. * Définir à 0 (défaut) pour la rendre infinie. * @return Notification */ function showNotification(title, body, timeout = 0) { Notification.requestPermission().then((status) => { if (status === 'granted') { // On envoie la notification que si la permission a été donnée let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"}) if (timeout > 0) setTimeout(() => notif.close(), timeout) return notif } }) } /** * Sélectionne le canal courant à afficher sur l'interface de chat. * Va alors définir le canal courant et mettre à jour les messages affichés. * @param channel_id L'identifiant du canal à afficher. */ function selectChannel(channel_id) { let channel = channels[channel_id] if (!channel) { // Le canal n'existe pas console.error('Channel not found:', channel_id) return } selected_channel_id = channel_id // On stocke dans le stockage local l'identifiant du canal // pour pouvoir rouvrir le dernier canal ouvert dans le futur localStorage.setItem('chat.last-channel-id', channel_id) // Définition du titre du contenu let channelTitle = document.getElementById('channel-title') channelTitle.innerText = channel.name // Si on a pas le droit d'écrire dans le canal, on désactive l'input de message // On l'active sinon let messageInput = document.getElementById('input-message') messageInput.disabled = !channel.write_access // On redessine la liste des messages à partir des messages stockés redrawMessages() } /** * On récupère le message écrit par l'utilisateur⋅rice dans le champ de texte idoine, * et on le transmet ensuite au serveur. * Il ne s'affiche pas instantanément sur l'interface, * mais seulement une fois que le serveur aura validé et retransmis le message. */ function sendMessage() { // Récupération du message à envoyer let messageInput = document.getElementById('input-message') let message = messageInput.value // On efface le champ de texte après avoir récupéré le message messageInput.value = '' if (!message) { return } // Envoi du message au serveur socket.send(JSON.stringify({ 'type': 'send_message', 'channel_id': selected_channel_id, 'content': message, })) } /** * Met à jour la liste des canaux disponibles, à partir de la liste récupérée du serveur. * @param new_channels La liste des canaux à afficher. * Chaque canal doit être un objet avec les clés `id`, `name`, `category` * `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal, * son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus. */ function setChannels(new_channels) { channels = {} for (let category of channel_categories) { // On commence par vider la liste des canaux sélectionnables let categoryList = document.getElementById(`nav-${category}-channels-tab`) categoryList.innerHTML = '' categoryList.parentElement.classList.add('d-none') } for (let channel of new_channels) // On ajoute chaque canal à la liste des canaux addChannel(channel) if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { // Si aucun canal n'a encore été sélectionné et qu'il y a des canaux disponibles, // on commence par vérifier si on a stocké un canal précédemment sélectionné et on l'affiche si c'est le cas // Sinon, on affiche le premier canal disponible let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id')) if (last_channel_id && channels[last_channel_id]) selectChannel(last_channel_id) else selectChannel(Object.keys(channels)[0]) } } /** * Ajoute un canal à la liste des canaux disponibles. * @param channel Le canal à ajouter. Doit être un objet avec les clés `id`, `name`, `category`, * `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal, * son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus. */ async function addChannel(channel) { channels[channel.id] = channel if (!messages[channel.id]) messages[channel.id] = new Map() // On récupère la liste des canaux de la catégorie concernée let categoryList = document.getElementById(`nav-${channel.category}-channels-tab`) // On la rend visible si elle ne l'était pas déjà categoryList.parentElement.classList.remove('d-none') // On crée un nouvel élément de liste pour la catégorie concernant le canal let navItem = document.createElement('li') navItem.classList.add('list-group-item', 'tab-channel') navItem.id = `tab-channel-${channel.id}` navItem.setAttribute('data-bs-dismiss', 'offcanvas') navItem.onclick = () => selectChannel(channel.id) categoryList.appendChild(navItem) // L'élément est cliquable afin de sélectionner le canal let channelButton = document.createElement('button') channelButton.classList.add('nav-link') channelButton.type = 'button' channelButton.innerText = channel.name navItem.appendChild(channelButton) // Affichage du nombre de messages non lus let unreadBadge = document.createElement('span') unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2') unreadBadge.id = `unread-messages-${channel.id}` unreadBadge.innerText = channel.unread_messages || 0 if (!channel.unread_messages) unreadBadge.classList.add('d-none') channelButton.appendChild(unreadBadge) // Si on veut trier les canaux par nombre décroissant de messages non lus, // on définit l'ordre de l'élément (propriété CSS) en fonction du nombre de messages non lus if (document.getElementById('sort-by-unread-switch').checked) navItem.style.order = `${-channel.unread_messages}` // On demande enfin à récupérer les derniers messages du canal en question afin de les stocker / afficher fetchMessages(channel.id) } /** * Un⋅e utilisateur⋅rice a envoyé un message, qui a été retransmis par le serveur. * On le stocke alors et on l'affiche sur l'interface si nécessaire. * On affiche également une notification si le message contient une mention pour tout le monde. * @param message Le message qui a été transmis. Doit être un objet avec * les clés `id`, `channel_id`, `author`, `author_id`, `content` et `timestamp`, * correspondant à l'identifiant du message, du canal, le nom de l'auteur⋅rice et l'heure d'envoi. */ function receiveMessage(message) { // On vérifie si la barre de défilement est tout en bas let scrollableContent = document.getElementById('chat-messages') let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1 // On stocke le message dans la liste des messages du canal concerné // et on redessine les messages affichés si on est dans le canal concerné messages[message.channel_id].set(message.id, message) if (message.channel_id === selected_channel_id) redrawMessages() // Si la barre de défilement était tout en bas, alors on la remet tout en bas après avoir redessiné les messages if (isScrolledToBottom) scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight // On ajoute un à la liste des messages non lus du canal (il pourra être lu plus tard) updateUnreadBadge(message.channel_id, channels[message.channel_id].unread_messages + 1) // Si le message contient une mention à @everyone, alors on envoie une notification (si la permission est donnée) if (message.content.includes("@everyone")) showNotification(channels[message.channel_id].name, `${message.author} : ${message.content}`) // On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour // Permettant entre autres de marquer le message comme lu si c'est le cas document.getElementById('message-list').dispatchEvent(new CustomEvent('updatemessages')) } /** * Un message a été modifié, et le serveur nous a transmis les nouvelles informations. * @param data Le nouveau message qui a été modifié. */ function editMessage(data) { // On met à jour le contenu du message messages[data.channel_id].get(data.id).content = data.content // Si le message appartient au canal courant, on redessine les messages if (data.channel_id === selected_channel_id) redrawMessages() } /** * Un message a été supprimé, et le serveur nous a transmis les informations. * @param data Le message qui a été supprimé. */ function deleteMessage(data) { // On supprime le message de la liste des messages du canal concerné messages[data.channel_id].delete(data.id) // Si le message appartient au canal courant, on redessine les messages if (data.channel_id === selected_channel_id) redrawMessages() } /** * Demande au serveur de récupérer les messages du canal donné. * @param channel_id L'identifiant du canal dont on veut récupérer les messages. * @param offset Le décalage à partir duquel on veut récupérer les messages, * correspond au nombre de messages en mémoire. * @param limit Le nombre maximal de messages à récupérer. */ function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) { // Envoi de la requête au serveur avec les différents paramètres socket.send(JSON.stringify({ 'type': 'fetch_messages', 'channel_id': channel_id, 'offset': offset, 'limit': limit, })) } /** * Demande au serveur de récupérer les messages précédents du canal courant. * Par défaut, on récupère `MAX_MESSAGES` messages avant tous ceux qui ont été reçus sur ce canal. */ function fetchPreviousMessages() { let channel_id = selected_channel_id let offset = messages[channel_id].size fetchMessages(channel_id, offset, MAX_MESSAGES) } /** * L'utilisateur⋅rice a demandé à récupérer une partie des messages d'un canal. * Cette fonction est alors appelée lors du retour du serveur. * @param data Dictionnaire contenant l'identifiant du canal concerné, et la liste des messages récupérés. */ function receiveFetchedMessages(data) { // Récupération du canal concerné ainsi que des nouveaux messages à mémoriser let channel_id = data.channel_id let new_messages = data.messages if (!messages[channel_id]) messages[channel_id] = new Map() // Ajout des nouveaux messages à la liste des messages du canal for (let message of new_messages) messages[channel_id].set(message.id, message) // On trie les messages reçus par date et heure d'envoi messages[channel_id] = new Map([...messages[channel_id].values()] .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)) .map(message => [message.id, message])) // Enfin, si le canal concerné est le canal courant, on redessine les messages if (channel_id === selected_channel_id) redrawMessages() } /** * L'utilisateur⋅rice a indiqué au serveur que des messages ont été lus. * Cette fonction est appelée en retour, pour confirmer, et stocke quels messages ont été lus * et combien de messages sont non lus par canal. * @param data Dictionnaire contenant une clé `read`, contenant la liste des identifiants des messages * marqués comme lus avec leur canal respectif, et une clé `unread_messages` contenant le nombre * de messages non lus par canal. */ function markMessageAsRead(data) { for (let message of data.messages) { // Récupération du message à marquer comme lu let stored_message = messages[message.channel_id].get(message.id) // Marquage du message comme lu if (stored_message) stored_message.read = true } // Actualisation des badges contenant le nombre de messages non lus par canal updateUnreadBadges(data.unread_messages) } /** * Mise à jour des badges contenant le nombre de messages non lus par canal. * @param unreadMessages Dictionnaire des nombres de messages non lus par canal (identifiés par leurs identifiants) */ function updateUnreadBadges(unreadMessages) { for (let channel of Object.values(channels)) { // Récupération du nombre de messages non lus pour le canal en question et mise à jour du badge pour ce canal updateUnreadBadge(channel.id, unreadMessages[channel.id] || 0) } } /** * Mise à jour du badge du nombre de messages non lus d'un canal. * Actualise sa visibilité. * @param channel_id Identifiant du canal concerné. * @param unreadMessagesCount Nombre de messages non lus du canal. */ function updateUnreadBadge(channel_id, unreadMessagesCount = 0) { // Vaut true si on veut trier les canaux par nombre de messages non lus ou non const sortByUnread = document.getElementById('sort-by-unread-switch').checked // Récupération du canal concerné let channel = channels[channel_id] // Récupération du nombre de messages non lus pour le canal en question, que l'on stocke channel.unread_messages = unreadMessagesCount // On met à jour le badge du canal contenant le nombre de messages non lus let unreadBadge = document.getElementById(`unread-messages-${channel.id}`) unreadBadge.innerText = unreadMessagesCount.toString() // Le badge est visible si et seulement si il y a au moins un message non lu if (unreadMessagesCount) unreadBadge.classList.remove('d-none') else unreadBadge.classList.add('d-none') // S'il faut trier les canaux par nombre de messages non lus, on ajoute la propriété CSS correspondante if (sortByUnread) document.getElementById(`tab-channel-${channel.id}`).style.order = `${-unreadMessagesCount}` } /** * La création d'un canal privé entre deux personnes a été demandée. * Cette fonction est appelée en réponse du serveur. * Le canal est ajouté à la liste s'il est nouveau, et automatiquement sélectionné. * @param data Dictionnaire contenant une unique clé `channel` correspondant aux informations du canal privé. */ function startPrivateChat(data) { // Récupération du canal let channel = data.channel if (!channel) { console.error('Private chat not found:', data) return } if (!channels[channel.id]) { // Si le canal n'est pas récupéré, on l'ajoute à la liste channels[channel.id] = channel messages[channel.id] = new Map() addChannel(channel) } // Sélection immédiate du canal privé selectChannel(channel.id) } /** * Met à jour le composant correspondant à la liste des messages du canal sélectionné. * Le conteneur est d'abord réinitialisé, puis les messages sont affichés un à un à partir de ceux stockés. */ function redrawMessages() { // Récupération du composant HTML <ul> correspondant à la liste des messages affichés let messageList = document.getElementById('message-list') // On commence par le vider messageList.innerHTML = '' let lastMessage = null let lastContentDiv = null for (let message of messages[selected_channel_id].values()) { if (lastMessage && lastMessage.author === message.author) { // Si le message est écrit par læ même auteur⋅rice que le message précédent, // alors on les groupe ensemble let lastTimestamp = new Date(lastMessage.timestamp) let newTimestamp = new Date(message.timestamp) if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) { // Les messages sont groupés uniquement s'il y a une différence maximale de 10 minutes // entre le premier message du groupe et celui en étude // On ajoute alors le contenu du message en cours dans le dernier div de message let messageContentDiv = document.createElement('div') messageContentDiv.classList.add('message') messageContentDiv.setAttribute('data-message-id', message.id) lastContentDiv.appendChild(messageContentDiv) let messageContentSpan = document.createElement('span') messageContentSpan.innerHTML = markdownToHTML(message.content) messageContentDiv.appendChild(messageContentSpan) // Enregistrement du menu contextuel pour le message permettant la modification, la suppression // et l'envoi de messages privés registerMessageContextMenu(message, messageContentDiv, messageContentSpan) continue } } // Création de l'élément <li> pour le bloc de messages let messageElement = document.createElement('li') messageElement.classList.add('list-group-item') messageList.appendChild(messageElement) // Ajout d'un div contenant le nom de l'auteur⋅rice du message ainsi que la date et heure d'envoi let authorDiv = document.createElement('div') messageElement.appendChild(authorDiv) // Ajout du nom de l'auteur⋅rice du message let authorSpan = document.createElement('span') authorSpan.classList.add('text-muted', 'fw-bold') authorSpan.innerText = message.author authorDiv.appendChild(authorSpan) // Ajout de la date du message let dateSpan = document.createElement('span') dateSpan.classList.add('text-muted', 'float-end') dateSpan.innerText = new Date(message.timestamp).toLocaleString() authorDiv.appendChild(dateSpan) // Enregistrement du menu contextuel pour le message permettant l'envoi de messages privés à l'auteur⋅rice registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan) let contentDiv = document.createElement('div') messageElement.appendChild(contentDiv) // Ajout du contenu du message // Le contenu est mis dans un span lui-même inclus dans un div, let messageContentDiv = document.createElement('div') messageContentDiv.classList.add('message') messageContentDiv.setAttribute('data-message-id', message.id) contentDiv.appendChild(messageContentDiv) let messageContentSpan = document.createElement('span') messageContentSpan.innerHTML = markdownToHTML(message.content) messageContentDiv.appendChild(messageContentSpan) // Enregistrement du menu contextuel pour le message permettant la modification, la suppression // et l'envoi de messages privés registerMessageContextMenu(message, messageContentDiv, messageContentSpan) lastMessage = message lastContentDiv = contentDiv } // Le bouton « Afficher les messages précédents » est affiché si et seulement si // il y a des messages à récupérer (c'est-à-dire si le nombre de messages récupérés est un multiple de MAX_MESSAGES) let fetchMoreButton = document.getElementById('fetch-previous-messages') if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0) fetchMoreButton.classList.add('d-none') else fetchMoreButton.classList.remove('d-none') // On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour // Permettant entre autres de marquer les messages visibles comme lus si c'est le cas messageList.dispatchEvent(new CustomEvent('updatemessages')) } /** * Convertit un texte écrit en Markdown en HTML. * Les balises Markdown suivantes sont supportées : * - Souligné : `_texte_` * - Gras : `**texte**` * - Italique : `*texte*` * - Code : `` `texte` `` * - Les liens sont automatiquement convertis * - Les esperluettes, guillemets et chevrons sont échappés. * @param text Le texte écrit en Markdown. * @return {string} Le texte converti en HTML. */ function markdownToHTML(text) { // On échape certains caractères spéciaux (esperluettes, chevrons, guillemets) let safeText = text.replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'") let lines = safeText.split('\n') let htmlLines = [] for (let line of lines) { // Pour chaque ligne, on remplace le Markdown par un équivalent HTML (pour ce qui est supporté) let htmlLine = line .replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>') // Souligné .replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>') // Gras .replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>') // Italique .replaceAll(/`(.*)`/gim, '<pre>$1</pre>') // Code .replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>') // Liens htmlLines.push(htmlLine) } // On joint enfin toutes les lignes par des balises de saut de ligne return htmlLines.join('<br>') } /** * Ferme toutes les popovers ouvertes. */ function removeAllPopovers() { for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) { let instance = bootstrap.Popover.getInstance(popover) if (instance) instance.dispose() } } /** * Enregistrement du menu contextuel pour un⋅e auteur⋅rice de message, * donnant la possibilité d'envoyer un message privé. * @param message Le message écrit par l'auteur⋅rice du bloc en question. * @param div Le bloc contenant le nom de l'auteur⋅rice et de la date d'envoi du message. * Un clic droit sur lui affichera le menu contextuel. * @param span Le span contenant le nom de l'auteur⋅rice. * Il désignera l'emplacement d'affichage du popover. */ function registerSendPrivateMessageContextMenu(message, div, span) { // Enregistrement de l'écouteur d'événement pour le clic droit div.addEventListener('contextmenu', (menu_event) => { // On empêche le menu traditionnel de s'afficher menu_event.preventDefault() // On retire toutes les popovers déjà ouvertes removeAllPopovers() // On crée le popover contenant le lien pour envoyer un message privé, puis on l'affiche const popover = bootstrap.Popover.getOrCreateInstance(span, { 'title': message.author, 'content': `<a id="send-private-message-link-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`, 'html': true, }) popover.show() // Lorsqu'on clique sur le lien, on ferme le popover // et on demande à ouvrir le canal privé avec l'auteur⋅rice du message document.getElementById('send-private-message-link-' + message.id).addEventListener('click', event => { event.preventDefault() popover.dispose() socket.send(JSON.stringify({ 'type': 'start_private_chat', 'user_id': message.author_id, })) }) }) } /** * Enregistrement du menu contextuel pour un message, * donnant la possibilité de modifier ou de supprimer le message, ou d'envoyer un message privé à l'auteur⋅rice. * @param message Le message en question. * @param div Le bloc contenant le contenu du message. * Un clic droit sur lui affichera le menu contextuel. * @param span Le span contenant le contenu du message. * Il désignera l'emplacement d'affichage du popover. */ function registerMessageContextMenu(message, div, span) { // Enregistrement de l'écouteur d'événement pour le clic droit div.addEventListener('contextmenu', (menu_event) => { // On empêche le menu traditionnel de s'afficher menu_event.preventDefault() // On retire toutes les popovers déjà ouvertes removeAllPopovers() // On crée le popover contenant les liens pour modifier, supprimer le message ou envoyer un message privé. let content = `<a id="send-private-message-link-msg-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>` // On ne peut modifier ou supprimer un message que si on est l'auteur⋅rice ou que l'on est administrateur⋅rice. let has_right_to_edit = message.author_id === USER_ID || IS_ADMIN if (has_right_to_edit) { content += `<hr class="my-1">` content += `<a id="edit-message-${message.id}" class="nav-link" href="#" tabindex="0">Modifier</a>` content += `<a id="delete-message-${message.id}" class="nav-link" href="#" tabindex="0">Supprimer</a>` } const popover = bootstrap.Popover.getOrCreateInstance(span, { 'content': content, 'html': true, 'placement': 'bottom', }) popover.show() // Lorsqu'on clique sur le lien d'envoi de message privé, on ferme le popover // et on demande à ouvrir le canal privé avec l'auteur⋅rice du message document.getElementById('send-private-message-link-msg-' + message.id).addEventListener('click', event => { event.preventDefault() popover.dispose() socket.send(JSON.stringify({ 'type': 'start_private_chat', 'user_id': message.author_id, })) }) if (has_right_to_edit) { // Si on a le droit de modifier ou supprimer le message, on enregistre les écouteurs d'événements // Le bouton de modification de message ouvre une boîte de dialogue pour modifier le message document.getElementById('edit-message-' + message.id).addEventListener('click', event => { event.preventDefault() // Fermeture du popover popover.dispose() // Ouverture d'une boîte de diaologue afin de modifier le message let new_message = prompt("Modifier le message", message.content) if (new_message) { // Si le message a été modifié, on envoie la demande de modification au serveur socket.send(JSON.stringify({ 'type': 'edit_message', 'message_id': message.id, 'content': new_message, })) } }) // Le bouton de suppression de message demande une confirmation avant de supprimer le message document.getElementById('delete-message-' + message.id).addEventListener('click', event => { event.preventDefault() // Fermeture du popover popover.dispose() // Demande de confirmation avant de supprimer le message if (confirm(`Supprimer le message ?\n${message.content}`)) { socket.send(JSON.stringify({ 'type': 'delete_message', 'message_id': message.id, })) } }) } }) } /** * Passe le chat en version plein écran, ou l'inverse si c'est déjà le cas. */ function toggleFullscreen() { let chatContainer = document.getElementById('chat-container') if (!chatContainer.getAttribute('data-fullscreen')) { // Le chat n'est pas en plein écran. // On le passe en plein écran en le plaçant en avant plan en position absolue // prenant toute la hauteur et toute la largeur chatContainer.setAttribute('data-fullscreen', 'true') chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') window.history.replaceState({}, null, `?fullscreen=1`) } else { // Le chat est déjà en plein écran. On retire les tags CSS correspondant au plein écran. chatContainer.removeAttribute('data-fullscreen') chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') window.history.replaceState({}, null, `?fullscreen=0`) } } document.addEventListener('DOMContentLoaded', () => { // Lorsqu'on effectue le moindre clic, on ferme les éventuelles popovers ouvertes document.addEventListener('click', removeAllPopovers) // Lorsqu'on change entre le tri des canaux par ordre alphabétique et par nombre de messages non lus, // on met à jour l'ordre des canaux document.getElementById('sort-by-unread-switch').addEventListener('change', event => { const sortByUnread = event.target.checked for (let channel of Object.values(channels)) { let item = document.getElementById(`tab-channel-${channel.id}`) if (sortByUnread) // Si on trie par nombre de messages non lus, // on définit l'ordre de l'élément en fonction du nombre de messages non lus // à l'aide d'une propriété CSS item.style.order = `${-channel.unread_messages}` else // Sinon, les canaux sont de base triés par ordre alphabétique item.style.removeProperty('order') } // On stocke le mode de tri dans le stockage local localStorage.setItem('chat.sort-by-unread', sortByUnread) }) // On récupère le mode de tri des canaux depuis le stockage local if (localStorage.getItem('chat.sort-by-unread') === 'true') { document.getElementById('sort-by-unread-switch').checked = true document.getElementById('sort-by-unread-switch').dispatchEvent(new Event('change')) } /** * Des données sont reçues depuis le serveur. Elles sont traitées dans cette fonction, * qui a pour but de trier et de répartir dans d'autres sous-fonctions. * @param data Le message reçu. */ function processMessage(data) { // On traite le message en fonction de son type switch (data.type) { case 'fetch_channels': setChannels(data.channels) break case 'send_message': receiveMessage(data) break case 'edit_message': editMessage(data) break case 'delete_message': deleteMessage(data) break case 'fetch_messages': receiveFetchedMessages(data) break case 'mark_read': markMessageAsRead(data) break case 'start_private_chat': startPrivateChat(data) break default: // Le type de message est inconnu. On affiche une erreur dans la console. console.log(data) console.error('Unknown message type:', data.type) break } } /** * Configuration du socket de chat, permettant de communiquer avec le serveur. * @param nextDelay Correspond au délai de reconnexion en cas d'erreur. * Augmente exponentiellement en cas d'erreurs répétées, * et se réinitialise à 1s en cas de connexion réussie. */ function setupSocket(nextDelay = 1000) { // Ouverture du socket socket = new WebSocket( (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/' ) let socketOpen = false // Écoute des messages reçus depuis le serveur socket.addEventListener('message', e => { // Analyse du message reçu en tant que JSON const data = JSON.parse(e.data) // Traite le message reçu processMessage(data) }) // En cas d'erreur, on affiche un message et on réessaie de se connecter après un certain délai // Ce délai double après chaque erreur répétée, jusqu'à un maximum de 2 minutes socket.addEventListener('close', e => { console.error('Chat socket closed unexpectedly, restarting…') setTimeout(() => setupSocket(Math.max(socketOpen ? 1000 : 2 * nextDelay, 120000)), nextDelay) }) // En cas de connexion réussie, on demande au serveur les derniers messages pour chaque canal socket.addEventListener('open', e => { socketOpen = true socket.send(JSON.stringify({ 'type': 'fetch_channels', })) }) } /** * Configuration du swipe pour ouvrir et fermer le sélecteur de canaux. * Fonctionne a priori uniquement sur les écrans tactiles. * Lorsqu'on swipe de la gauche vers la droite, depuis le côté gauche de l'écran, on ouvre le sélecteur de canaux. * Quand on swipe de la droite vers la gauche, on ferme le sélecteur de canaux. */ function setupSwipeOffscreen() { // Récupération du sélecteur de canaux const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector')) // L'écran a été touché. On récupère la coordonnée X de l'emplacement touché. let lastX = null document.addEventListener('touchstart', (event) => { if (event.touches.length === 1) lastX = event.touches[0].clientX }) // Le doigt a été déplacé. Selon le nouvel emplacement du doigt, on ouvre ou on ferme le sélecteur de canaux. document.addEventListener('touchmove', (event) => { if (event.touches.length === 1 && lastX !== null) { // L'écran a été touché à un seul doigt, et on a déjà récupéré la coordonnée X touchée. const diff = event.touches[0].clientX - lastX if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) { // Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la droite // et que le point de départ se trouve dans le quart gauche de l'écran, alors on ouvre le sélecteur offcanvas.show() lastX = null } else if (diff < -window.innerWidth / 10) { // Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la gauche, // alors on ferme le sélecteur offcanvas.hide() lastX = null } } }) // Le doigt a été relâché. On réinitialise la coordonnée X touchée. document.addEventListener('touchend', () => { lastX = null }) } /** * Configuration du suivi de lecture des messages. * Lorsque l'utilisateur⋅rice scrolle dans la fenêtre de chat, on vérifie quels sont les messages qui sont * visibles à l'écran, et on les marque comme lus. */ function setupReadTracker() { // Récupération du conteneur de messages const scrollableContent = document.getElementById('chat-messages') const messagesList = document.getElementById('message-list') let markReadBuffer = [] let markReadTimeout = null // Lorsqu'on scrolle, on récupère les anciens messages si on est tout en haut, // et on marque les messages visibles comme lus scrollableContent.addEventListener('scroll', () => { if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight && !document.getElementById('fetch-previous-messages').classList.contains('d-none')) { // Si l'utilisateur⋅rice est en haut du chat, on récupère la liste des anciens messages fetchPreviousMessages()} // On marque les messages visibles comme lus markVisibleMessagesAsRead() }) // Lorsque les messages stockés sont mis à jour, on vérifie quels sont les messages visibles à marquer comme lus messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead()) /** * Marque les messages visibles à l'écran comme lus. * On récupère pour cela les coordonnées du conteneur de messages ainsi que les coordonnées de chaque message * et on vérifie si le message est visible à l'écran. Si c'est le cas, on le marque comme lu. * Après 3 secondes d'attente après qu'aucun message n'ait été lu, * on envoie la liste des messages lus au serveur. */ function markVisibleMessagesAsRead() { // Récupération des coordonnées visibles du conteneur de messages let viewport = scrollableContent.getBoundingClientRect() for (let item of messagesList.querySelectorAll('.message')) { let message = messages[selected_channel_id].get(parseInt(item.getAttribute('data-message-id'))) if (!message.read) { // Si le message n'a pas déjà été lu, on récupère ses coordonnées let rect = item.getBoundingClientRect() if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) { // Si les coordonnées sont entièrement incluses dans le rectangle visible, on le marque comme lu // et comme étant à envoyer au serveur message.read = true markReadBuffer.push(message.id) if (markReadTimeout) clearTimeout(markReadTimeout) // 3 secondes après qu'aucun nouveau message n'ait été rajouté, on envoie la liste des messages // lus au serveur markReadTimeout = setTimeout(() => { socket.send(JSON.stringify({ 'type': 'mark_read', 'message_ids': markReadBuffer, })) markReadBuffer = [] markReadTimeout = null }, 3000) } } } } // On considère les messages d'ores-et-déjà visibles comme lus markVisibleMessagesAsRead() } /** * Configuration de la demande d'installation de l'application en tant qu'application web progressive (PWA). * Lorsque l'utilisateur⋅rice arrive sur la page, on lui propose de télécharger l'application * pour l'ajouter à son écran d'accueil. * Fonctionne uniquement sur les navigateurs compatibles. */ function setupPWAPrompt() { let deferredPrompt = null window.addEventListener("beforeinstallprompt", (e) => { // Une demande d'installation a été faite. On commence par empêcher l'action par défaut. e.preventDefault() deferredPrompt = e // L'installation est possible, on rend visible le bouton de téléchargement // ainsi que le message qui indique c'est possible. let btn = document.getElementById('install-app-home-screen') let alert = document.getElementById('alert-download-chat-app') btn.classList.remove('d-none') alert.classList.remove('d-none') btn.onclick = function () { // Lorsque le bouton de téléchargement est cliqué, on lance l'installation du PWA. deferredPrompt.prompt() deferredPrompt.userChoice.then((choiceResult) => { if (choiceResult.outcome === 'accepted') { // Si l'installation a été acceptée, on masque le bouton de téléchargement. deferredPrompt = null btn.classList.add('d-none') alert.classList.add('d-none') } }) } }) } setupSocket() // Configuration du Websocket setupSwipeOffscreen() // Configuration du swipe sur les écrans tactiles pour le sélecteur de canaux setupReadTracker() // Configuration du suivi de lecture des messages setupPWAPrompt() // Configuration de l'installateur d'application en tant qu'application web progressive })