From 627e9299c15f302236864f9cdf020d1df298ebc8 Mon Sep 17 00:00:00 2001 From: Miroslav Pejic Date: Wed, 22 Apr 2026 20:46:19 +0200 Subject: [PATCH] [mirotalk] - t(chat): add emoji reactions to chat messages --- .env.template | 2 +- app/src/config.template.js | 2 +- app/src/server.js | 2 +- package-lock.json | 20 +-- package.json | 6 +- public/css/client.css | 205 +++++++++++++++++++++++++- public/js/brand.js | 2 +- public/js/client.js | 287 +++++++++++++++++++++++++++++++++++-- 8 files changed, 494 insertions(+), 32 deletions(-) diff --git a/.env.template b/.env.template index 1cf8ae1d..833279a8 100644 --- a/.env.template +++ b/.env.template @@ -1,5 +1,5 @@ # ==================================================== -# MiroTalk P2P v.1.8.07 - Environment Configuration +# MiroTalk P2P v.1.8.08 - Environment Configuration # ==================================================== # App environment diff --git a/app/src/config.template.js b/app/src/config.template.js index 64ed2b16..e3fde1e1 100644 --- a/app/src/config.template.js +++ b/app/src/config.template.js @@ -2,7 +2,7 @@ /** * ============================================== - * MiroTalk P2P v.1.8.07 - Configuration File + * MiroTalk P2P v.1.8.08 - Configuration File * ============================================== * * This file is the central configuration source. diff --git a/app/src/server.js b/app/src/server.js index 009e90df..2f3e01e9 100755 --- a/app/src/server.js +++ b/app/src/server.js @@ -45,7 +45,7 @@ dependencies: { * @license For commercial use or closed source, contact us at license.mirotalk@gmail.com or purchase directly from CodeCanyon * @license CodeCanyon: https://codecanyon.net/item/mirotalk-p2p-webrtc-realtime-video-conferences/38376661 * @author Miroslav Pejic - miroslav.pejic.85@gmail.com - * @version 1.8.07 + * @version 1.8.08 * */ diff --git a/package-lock.json b/package-lock.json index 2e99608d..afc60a9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,24 @@ { "name": "mirotalk", - "version": "1.8.07", + "version": "1.8.08", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mirotalk", - "version": "1.8.07", + "version": "1.8.08", "license": "AGPL-3.0", "dependencies": { "@mattermost/client": "11.6.0", "@ngrok/ngrok": "1.7.0", "@sentry/node": "^10.49.0", - "axios": "^1.15.1", + "axios": "^1.15.2", "chokidar": "^5.0.0", "colors": "^1.4.0", "compression": "^1.8.1", "cors": "^2.8.6", "crypto-js": "^4.2.0", - "dompurify": "^3.4.0", + "dompurify": "^3.4.1", "dotenv": "^17.4.2", "express": "^5.2.1", "express-openid-connect": "^2.20.2", @@ -1725,9 +1725,9 @@ } }, "node_modules/axios": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", - "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -2508,9 +2508,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", - "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/package.json b/package.json index 8c00d48f..e3a535d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mirotalk", - "version": "1.8.07", + "version": "1.8.08", "description": "A free WebRTC browser-based video call", "main": "server.js", "scripts": { @@ -47,13 +47,13 @@ "@mattermost/client": "11.6.0", "@ngrok/ngrok": "1.7.0", "@sentry/node": "^10.49.0", - "axios": "^1.15.1", + "axios": "^1.15.2", "chokidar": "^5.0.0", "colors": "^1.4.0", "compression": "^1.8.1", "cors": "^2.8.6", "crypto-js": "^4.2.0", - "dompurify": "^3.4.0", + "dompurify": "^3.4.1", "dotenv": "^17.4.2", "express": "^5.2.1", "express-openid-connect": "^2.20.2", diff --git a/public/css/client.css b/public/css/client.css index 94e2dc74..4a4d340a 100755 --- a/public/css/client.css +++ b/public/css/client.css @@ -1604,11 +1604,12 @@ body { .private-msg-bubble, .msg-bubble { + position: relative; width: auto; max-width: min(78%, 720px); padding: 12px 14px; border-radius: 20px; - overflow: hidden; + overflow: visible; } .msg-caption-bubble { @@ -1648,18 +1649,216 @@ body { border-top: 1px solid rgba(255, 255, 255, 0.12); } -.msg-text button { +.msg-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.msg-footer { + display: flex; + flex-direction: column; + margin-top: 6px; +} + +.msg-actions button { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; - margin-right: 6px; border-radius: 10px; background: var(--body-bg) !important; } +.reaction-picker { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + padding: 6px; + border-radius: 12px; + border: var(--border); + background: rgba(18, 18, 20, 0.95); + box-shadow: 0 14px 24px rgba(0, 0, 0, 0.22); +} + +.reaction-emoji-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 10px; + padding: 0; + font-size: 18px; + line-height: 1; + background: rgba(255, 255, 255, 0.12); + cursor: pointer; +} + +.reaction-emoji-btn:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.22); +} + +.reaction-emoji-btn:focus { + outline: 2px solid rgba(102, 170, 255, 0.6); + outline-offset: 2px; +} + +.reaction-toggle-btn { + font-size: 16px; +} + +.message-reactions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.reaction-badge { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 24px; + padding: 1px 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + font-size: 11px; + line-height: 1.2; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.reaction-badge:hover { + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.32); + transform: translateY(-1px); +} + +.reaction-badge:active { + transform: translateY(1px) scale(0.97); +} + +.reaction-badge.my-reaction { + background: rgba(53, 125, 255, 0.3); + border-color: rgba(102, 170, 255, 0.8); + font-weight: 500; +} + +.reaction-badge.my-reaction:hover { + background: rgba(53, 125, 255, 0.4); + border-color: rgba(102, 170, 255, 1); +} + +.reaction-badge::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: 999px; + border: 1px solid rgba(102, 170, 255, 0.5); + opacity: 0; + transition: opacity 0.2s ease; +} + +.reaction-badge.my-reaction::before { + opacity: 1; +} + +.reaction-badge:hover::after { + content: attr(data-peers); + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%); + max-width: 280px; + width: max-content; + padding: 6px 10px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + color: #fff; + background: rgba(0, 0, 0, 0.95); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); +} + +/* Mobile optimizations for emoji reactions */ +@media (max-width: 768px) { + .reaction-emoji-btn { + width: 44px; + height: 44px; + font-size: 22px; + } + + .msg-actions button { + width: 40px; + height: 40px; + } + + .reaction-picker { + gap: 10px; + padding: 10px; + } + + .reaction-badge { + min-width: 30px; + height: 22px; + padding: 1px 4px; + font-size: 11px; + } +} + +@media (max-width: 768px) and (hover: none) { + .reaction-emoji-btn:hover { + transform: none; + background: rgba(255, 255, 255, 0.12); + } + + .reaction-emoji-btn:active { + transform: scale(1.15) translateY(-3px); + background: rgba(255, 255, 255, 0.25); + } + + .reaction-badge:hover { + transform: none; + } + + .reaction-badge:active { + transform: scale(0.95); + } +} + +@media (max-width: 600px) { + .reaction-emoji-btn { + width: 48px; + height: 48px; + } + + .msg-actions { + gap: 8px; + } + + .reaction-badge { + min-width: 28px; + height: 20px; + padding: 0px 3px; + font-size: 11px; + } +} + .msg-text iframe { width: 100%; } diff --git a/public/js/brand.js b/public/js/brand.js index c07e4af2..f1c17504 100644 --- a/public/js/brand.js +++ b/public/js/brand.js @@ -109,7 +109,7 @@ let brand = { }, about: { imageUrl: '../images/mirotalk-logo.gif', - title: 'WebRTC P2P v1.8.07', + title: 'WebRTC P2P v1.8.08', html: ` `; + msgHTML += ` + `; if (isSpeechSynthesisSupported) { msgHTML += ` `; } msgHTML += ` + +
+ @@ -10490,6 +10721,7 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '') if (!isMobileDevice) { setTippy(getId('msg-delete-' + chatMessagesId), 'Delete', 'top'); setTippy(getId('msg-copy-' + chatMessagesId), 'Copy', 'top'); + setTippy(getId('msg-reaction-' + chatMessagesId), 'React', 'top'); setTippy(getId('msg-speech-' + chatMessagesId), 'Speech', 'top'); } chatMessagesId++; @@ -11267,8 +11499,9 @@ function addMsgerPrivateBtn( } const toPeerName = msgerPrivateBtn.dataset.value; - emitMsg(myPeerName, myPeerAvatar, toPeerName, pMsg, true, myPeerId); - appendMessage(myPeerName, rightChatAvatar, 'right', pMsg, true, null, toPeerName); + const msgId = createChatMessageId(); + emitMsg(myPeerName, myPeerAvatar, toPeerName, pMsg, true, myPeerId, msgId); + appendMessage(myPeerName, rightChatAvatar, 'right', pMsg, true, msgId, toPeerName); msgerPrivateMsgInput.value = ''; if (!shouldDockParticipantsPanel()) { syncParticipantsPanelVisibility(false); @@ -11532,7 +11765,7 @@ function getFormatDate(date) { * @param {boolean} privateMsg if is a private message * @param {string} id peer_id */ -function emitMsg(from, fromAvatar, to, msg, privateMsg, id) { +function emitMsg(from, fromAvatar, to, msg, privateMsg, id, msgId = '') { if (!msg) return; // sanitize all params @@ -11543,6 +11776,7 @@ function emitMsg(from, fromAvatar, to, msg, privateMsg, id) { const getMsg = filterXSS(msg); const getPrivateMsg = filterXSS(privateMsg); const getId = filterXSS(id); + const getMsgId = normalizeChatMessageId(filterXSS(msgId)); const chatMessage = { type: 'chat', @@ -11550,6 +11784,7 @@ function emitMsg(from, fromAvatar, to, msg, privateMsg, id) { fromAvatar: getFromAvatar, fromId: getFromId, id: getId, + msg_id: getMsgId, to: getTo, msg: getMsg, privateMsg: getPrivateMsg, @@ -12352,6 +12587,34 @@ function handleMessage(message) { } } +/** + * Handle incoming chat reactions. + * @param {object} data + */ +function handleChatReaction(data) { + if (!data) return; + + const rawMsgId = String(data.msg_id || '').trim(); + const msgId = rawMsgId.replace(/[^a-zA-Z0-9:_-]/g, ''); + const emoji = filterXSS(data.emoji || ''); + const peerName = filterXSS(data.peer_name || ''); + const action = data.action === 'remove' ? 'remove' : 'add'; + + if (!msgId || !emoji || !peerName) return; + if (!CHAT_REACTION_EMOJIS.includes(emoji)) return; + + const messageElement = getChatMessageElement(msgId); + if (!messageElement) return; + + applyReactionToElement(messageElement, emoji, peerName, action); +} + +document.addEventListener('click', (event) => { + const target = event.target; + if (target?.closest('.reaction-picker') || target?.closest('.reaction-toggle-btn')) return; + msgerChat?.querySelectorAll('.reaction-picker').forEach((picker) => picker.remove()); +}); + /** * Handle room emoji reaction * @param {object} message @@ -15002,7 +15265,7 @@ function showAbout() { Swal.fire({ background: swBg, position: 'center', - title: brand.about?.title && brand.about.title.trim() !== '' ? brand.about.title : 'WebRTC P2P v1.8.07', + title: brand.about?.title && brand.about.title.trim() !== '' ? brand.about.title : 'WebRTC P2P v1.8.08', imageUrl: brand.about?.imageUrl && brand.about.imageUrl.trim() !== '' ? brand.about.imageUrl : images.about, customClass: { image: 'img-about' }, html: `