From 600357444d8a4386657ad7fb108bb5abf04d91fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sat, 2 Sep 2023 21:18:00 -0400 Subject: [PATCH] feat: typing user list in chat --- install/package.json | 4 +-- public/language/en-GB/modules.json | 5 +++- public/src/client/chats.js | 29 +++++++++++++++++++- public/src/client/header/chat.js | 8 +++--- public/src/modules/chat.js | 44 ++++++++++++++++++++++++++---- public/src/utils.common.js | 4 +-- src/socket.io/index.js | 13 +++++++++ src/socket.io/modules.js | 17 ++++++++++++ 8 files changed, 108 insertions(+), 16 deletions(-) diff --git a/install/package.json b/install/package.json index ea887a5bb1..fa9e9e3556 100644 --- a/install/package.json +++ b/install/package.json @@ -102,10 +102,10 @@ "nodebb-plugin-ntfy": "1.5.0", "nodebb-plugin-spam-be-gone": "2.1.1", "nodebb-rewards-essentials": "0.2.3", - "nodebb-theme-harmony": "1.1.50", + "nodebb-theme-harmony": "1.1.51", "nodebb-theme-lavender": "7.1.3", "nodebb-theme-peace": "2.1.18", - "nodebb-theme-persona": "13.2.25", + "nodebb-theme-persona": "13.2.26", "nodebb-widget-essentials": "7.0.13", "nodemailer": "6.9.4", "nprogress": "0.2.0", diff --git a/public/language/en-GB/modules.json b/public/language/en-GB/modules.json index 517873ca6c..d348e75e7c 100644 --- a/public/language/en-GB/modules.json +++ b/public/language/en-GB/modules.json @@ -9,7 +9,10 @@ "chat.chat-with-usernames-and-x-others": "Chat with %1 & %2 others", "chat.send": "Send", "chat.no_active": "You have no active chats.", - "chat.user_typing": "%1 is typing ...", + "chat.user_typing_1": "%1 is typing ...", + "chat.user_typing_2": "%1 and %2 are typing ...", + "chat.user_typing_3": "%1, %2 and %3 are typing ...", + "chat.user_typing_n": "%1, %2 and %3 others are typing ...", "chat.user_has_messaged_you": "%1 has messaged you.", "chat.replying-to": "Replying to %1", "chat.see_all": "All chats", diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 6d86912da1..140f64c036 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -18,11 +18,12 @@ define('forum/chats', [ 'chat', 'api', 'uploadHelpers', + 'translator', ], function ( components, mousetrap, recentChats, create, manage, messages, userList, messageSearch, pinnedMessages, autocomplete, hooks, bootbox, alerts, chatModule, api, - uploadHelpers + uploadHelpers, translator ) { const Chats = { initialised: false, @@ -89,6 +90,7 @@ define('forum/chats', [ Chats.addParentHandler(mainWrapper); Chats.addCharactersLeftHandler(mainWrapper); Chats.addTextareaResizeHandler(mainWrapper); + Chats.addTypingHandler(mainWrapper, roomId); Chats.addIPHandler(mainWrapper); Chats.createAutoComplete(roomId, $('[component="chat/input"]')); Chats.addUploadHandler({ @@ -313,6 +315,23 @@ define('forum/chats', [ }); }; + Chats.addTypingHandler = function (parent, roomId) { + const textarea = parent.find('[component="chat/input"]'); + function emitTyping(typing) { + socket.emit('modules.chats.typing', { + roomId: roomId, + typing: typing, + username: app.user.username, + }); + } + + textarea.on('focus', () => emitTyping(!!textarea.val())); + textarea.on('blur', () => emitTyping(false)); + textarea.on('input', utils.throttle(function () { + emitTyping(!!textarea.val()); + }, 2500, true)); + }; + Chats.addActionHandlers = function (element, roomId) { element.on('click', '[data-mid] [data-action]', function () { const msgEl = $(this).parents('[data-mid]'); @@ -544,6 +563,7 @@ define('forum/chats', [ const html = await app.parseAndTranslate('partials/chats/message-window', payload); const mainWrapper = components.get('chat/main-wrapper'); mainWrapper.html(html); + mainWrapper.attr('data-roomid', roomId); chatNavWrapper = $('[component="chat/nav-wrapper"]'); html.find('.timeago').timeago(); ajaxify.data = { ...ajaxify.data, ...payload, roomId: roomId }; @@ -636,6 +656,13 @@ define('forum/chats', [ } }); }); + + socket.on('event:chats.typing', async (data) => { + if (chatModule.isFromBlockedUser(data.uid)) { + return; + } + chatModule.updateTypingUserList($(`[component="chat/main-wrapper"][data-roomid="${data.roomId}"]`), data); + }); }; Chats.markChatPageElUnread = function (data) { diff --git a/public/src/client/header/chat.js b/public/src/client/header/chat.js index e384291945..44f9cf4a53 100644 --- a/public/src/client/header/chat.js +++ b/public/src/client/header/chat.js @@ -25,8 +25,8 @@ define('forum/header/chat', [ socket.removeListener('event:chats.receive', onChatMessageReceived); socket.on('event:chats.receive', onChatMessageReceived); - socket.removeListener('event:user_status_change', onUserStatusChange); - socket.on('event:user_status_change', onUserStatusChange); + socket.removeListener('event:chats.typing', onUserTyping); + socket.on('event:chats.typing', onUserTyping); socket.removeListener('event:chats.roomRename', onRoomRename); socket.on('event:chats.roomRename', onRoomRename); @@ -63,8 +63,8 @@ define('forum/header/chat', [ requireAndCall('onChatMessageReceived', data); } - function onUserStatusChange(data) { - requireAndCall('onUserStatusChange', data); + function onUserTyping(data) { + requireAndCall('onUserTyping', data); } function onRoomRename(data) { diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index b64e7d144f..0f734c908e 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -226,11 +226,6 @@ define('chat', [ }); } - module.onUserStatusChange = function (data) { - const modal = module.getModal(data.uid); - app.updateUserStatus(modal.find('[component="user/status"]'), data.status); - }; - module.onRoomRename = function (data) { const modal = module.getModal(data.roomId); const titleEl = modal.find('[component="chat/room/name"]'); @@ -252,6 +247,44 @@ define('chat', [ })); }; + module.onUserTyping = function (data) { + if (module.isFromBlockedUser(data.uid)) { + return; + } + const modal = module.getModal(data.roomId); + if (modal.length) { + module.updateTypingUserList(modal, data); + } + }; + + module.updateTypingUserList = async function (container, { uid, username, typing }) { + const typingEl = container.find(`[component="chat/composer/typing"]`); + const typingUsersList = typingEl.find('[component="chat/composer/typing/users"]'); + const userEl = typingUsersList.find(`[data-uid="${uid}"]`); + + if (typing && !userEl.length) { + $(`
`).attr('data-uid', uid) + .text(username) + .appendTo(typingUsersList); + } else if (!typing && userEl.length) { + userEl.remove(); + } + + const usernames = []; + typingUsersList.children().each((i, el) => { + usernames.push($(el).text()); + }); + + const typingTextEl = typingEl.find('[component="chat/composer/typing/text"]'); + const count = usernames.length > 3 ? 'n' : usernames.length; + if (count) { + const key = `modules:chat.user_typing_${count}`; + const compiled = translator.compile.apply(null, [key, ...usernames]); + typingTextEl.html(await translator.translate(compiled)); + } + typingTextEl.toggleClass('hidden', !usernames.length); + }; + module.getModal = function (roomId) { return $('#chat-modal-' + roomId); }; @@ -370,6 +403,7 @@ define('chat', [ Chats.addParentHandler(chatModal.find('[component="chat/message/content"]')); Chats.addCharactersLeftHandler(chatModal); Chats.addTextareaResizeHandler(chatModal); + Chats.addTypingHandler(chatModal, roomId); Chats.addIPHandler(chatModal); Chats.addTooltipHandler(chatModal); Chats.addUploadHandler({ diff --git a/public/src/utils.common.js b/public/src/utils.common.js index 5773fa1675..dd01af95c2 100644 --- a/public/src/utils.common.js +++ b/public/src/utils.common.js @@ -707,9 +707,7 @@ const utils = { const args = arguments; const later = function () { timeout = null; - if (!immediate) { - func.apply(context, args); - } + func.apply(context, args); }; const callNow = immediate && !timeout; if (!timeout) { diff --git a/src/socket.io/index.js b/src/socket.io/index.js index d55b73b37c..6db0719380 100644 --- a/src/socket.io/index.js +++ b/src/socket.io/index.js @@ -97,6 +97,19 @@ function onConnection(socket) { }, onMessage, socket, payload); }); + socket.on('disconnecting', () => { + for (const room of socket.rooms) { + if (room && room.match(/^chat_room_\d+$/)) { + Sockets.server.in(room).emit('event:chats.typing', { + roomId: room.split('_').pop(), + uid: socket.uid, + username: '', + typing: false, + }); + } + } + }); + socket.on('disconnect', () => { onDisconnect(socket); }); diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index cfb83d7a16..c58c137961 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -1,6 +1,7 @@ 'use strict'; const _ = require('lodash'); +const validator = require('validator'); const db = require('../database'); const Messaging = require('../messaging'); @@ -264,5 +265,21 @@ SocketModules.chats.loadPinnedMessages = async (socket, data) => { return pinnedMsgs; }; +SocketModules.chats.typing = async (socket, data) => { + if (!data || !utils.isNumber(data.roomId) || typeof data.typing !== 'boolean') { + throw new Error('[[error:invalid-data]]'); + } + const isInRoom = await Messaging.isUserInRoom(socket.uid, data.roomId); + if (!isInRoom) { + throw new Error('[[error:no-privileges]]'); + } + socket.to(`chat_room_${data.roomId}`).emit('event:chats.typing', { + uid: socket.uid, + roomId: data.roomId, + typing: data.typing, + username: validator.escape(String(data.username)), + }); +}; + require('../promisify')(SocketModules);