[mirotalk] - refactor(chat): migrate room UI markup to templates and CSS, fix private/chatgpt entries, and keep sender info visible

This commit is contained in:
Miroslav Pejic
2026-05-02 23:03:33 +02:00
parent 18107ba5fd
commit 3301dfbe4d
10 changed files with 556 additions and 208 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# ====================================================
# MiroTalk P2P v.1.8.28 - Environment Configuration
# MiroTalk P2P v.1.8.30 - Environment Configuration
# ====================================================
# App environment
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* ==============================================
* MiroTalk P2P v.1.8.28 - Configuration File
* MiroTalk P2P v.1.8.30 - Configuration File
* ==============================================
*
* This file is the central configuration source.
+1 -1
View File
@@ -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.28
* @version 1.8.30
*
*/
+7 -7
View File
@@ -1,18 +1,18 @@
{
"name": "mirotalk",
"version": "1.8.28",
"version": "1.8.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mirotalk",
"version": "1.8.28",
"version": "1.8.30",
"license": "AGPL-3.0",
"dependencies": {
"@mattermost/client": "11.6.0",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "^10.51.0",
"axios": "^1.15.2",
"axios": "^1.16.0",
"chokidar": "^5.0.0",
"colors": "^1.4.0",
"compression": "^1.8.1",
@@ -1718,12 +1718,12 @@
}
},
"node_modules/axios": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mirotalk",
"version": "1.8.28",
"version": "1.8.30",
"description": "A free WebRTC browser-based video call",
"main": "server.js",
"scripts": {
@@ -47,7 +47,7 @@
"@mattermost/client": "11.6.0",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "^10.51.0",
"axios": "^1.15.2",
"axios": "^1.16.0",
"chokidar": "^5.0.0",
"colors": "^1.4.0",
"compression": "^1.8.1",
+18 -3
View File
@@ -1996,15 +1996,15 @@ body {
}
.msg-grouped .msg-img {
visibility: hidden;
visibility: visible;
}
.msg-grouped .msg-info-name {
display: none;
display: inline;
}
.msg-grouped .msg-info {
justify-content: flex-end;
justify-content: space-between;
}
.msger-copy-txt {
@@ -2902,6 +2902,16 @@ button {
align-items: center;
}
.share-room-modal-highlight {
color: rgb(8, 189, 89);
}
.share-room-modal-description {
background: transparent;
color: #fff;
font-family: Arial, Helvetica, sans-serif;
}
#qrRoomPopupContainer {
z-index: 9999;
position: fixed;
@@ -2930,6 +2940,11 @@ button {
height: 256px;
}
.kicked-out-modal-alert-title,
.kicked-out-modal-alert-time {
color: #ff2d00;
}
/*--------------------------------------------------------------
# My settings
--------------------------------------------------------------*/
+1 -1
View File
@@ -109,7 +109,7 @@ let brand = {
},
about: {
imageUrl: '../images/mirotalk-logo.gif',
title: 'WebRTC P2P v1.8.28',
title: 'WebRTC P2P v1.8.30',
html: `
<button
id="support-button"
+179 -192
View File
@@ -15,7 +15,7 @@
* @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.28
* @version 1.8.30
*
*/
@@ -1052,7 +1052,11 @@ function getInfo() {
})
.join('');
extraInfo.innerHTML = `<div class="extra-info-grid">${rows}</div>`;
extraInfo.innerHTML = renderRoomTemplate('tpl-extra-info-grid', {
html: {
rows,
},
});
return parserResult;
} catch (error) {
@@ -1611,7 +1615,11 @@ function roomIsBusy() {
imageUrl: images.forbidden,
position: 'center',
title: 'Room is busy',
html: `The room is limited to ${thisMaxRoomParticipants} users. <br/> Please try again later`,
html: renderRoomTemplate('tpl-room-busy-message', {
text: {
maxUsers: String(thisMaxRoomParticipants),
},
}),
showDenyButton: false,
confirmButtonText: `OK`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
@@ -1840,7 +1848,14 @@ function renderDynamicThemeCards() {
card.className = 'theme-card';
card.dataset.theme = name;
card.dataset.index = index;
card.innerHTML = `<i class="${iconClass}"></i><span>${option.textContent}</span>`;
card.innerHTML = renderRoomTemplate('tpl-theme-card-content', {
text: {
label: option.textContent,
},
attrs: {
iconClass,
},
});
// Apply dynamic icon color via inline style
const icon = card.querySelector('i');
@@ -2118,7 +2133,7 @@ function userNameAlreadyInRoom() {
imageUrl: images.forbidden,
position: 'center',
title: 'Username',
html: `The Username is already in use. <br/> Please try with another one`,
html: renderRoomTemplate('tpl-username-in-use-message'),
showDenyButton: false,
confirmButtonText: `OK`,
showClass: { popup: 'animate__animated animate__fadeInDown' },
@@ -8370,14 +8385,11 @@ function shareRoomMeetingURL(checkScreen = false) {
background: swBg,
position: 'center',
title: 'Share the room',
html: `
<div id="qrRoomContainer">
<canvas id="qrRoom"></canvas>
</div>
<br/>
<p style="color:rgb(8, 189, 89);">Join from your mobile device</p>
<p style="background:transparent; color:white; font-family: Arial, Helvetica, sans-serif;">No need for apps, simply capture the QR code with your mobile camera Or Invite someone else to join by sending them the following URL</p>
<p style="color:rgb(8, 189, 89);">${roomURL}</p>`,
html: renderRoomTemplate('tpl-share-room-modal', {
text: {
roomURL,
},
}),
showDenyButton: true,
showCancelButton: true,
cancelButtonColor: 'red',
@@ -9731,7 +9743,11 @@ async function downloadRecordedStream() {
</ul>
<br/>
`;
lastRecordingInfo.innerHTML = `<br/>Last recording info: ${recordingInfo}`;
lastRecordingInfo.innerHTML = renderRoomTemplate('tpl-last-recording-info', {
html: {
recordingInfo,
},
});
recordingTime.innerText = '';
msgHTML(
@@ -10719,17 +10735,15 @@ function handleSpeechTranscript(config) {
// parser decodes back to " in attribute context (double-decode XSS).
// Use a temporary id and setAttribute instead.
const captionAvatarTmpId = `capt-av-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const msgHTML = `
<div class="msg left-msg">
<img class="msg-img" id="${captionAvatarTmpId}" />
<div class="msg-caption-bubble">
<div class="msg-info">
<div class="msg-info-name">${peer_name} : ${time_stamp}</div>
</div>
<div class="msg-text">${text_data}</div>
</div>
</div>
`;
const msgHTML = renderRoomTemplate('tpl-caption-message', {
text: {
captionInfoText: `${peer_name} : ${time_stamp}`,
captionText: text_data,
},
attrs: {
captionAvatarTmpId,
},
});
captionChat.insertAdjacentHTML('beforeend', msgHTML);
const captionAvatarEl = document.getElementById(captionAvatarTmpId);
if (captionAvatarEl) {
@@ -10810,23 +10824,7 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '')
// getImg is a user-controlled URL; use a temporary id and setAttribute
// after insertion to avoid double-decode XSS via insertAdjacentHTML.
const msgAvatarTmpId = `msg-av-${chatMessagesId}`;
let msgHTML = `
<div id="msg-${chatMessagesId}" class="msg ${getSide}-msg" data-sender="${getFrom}" data-chat-type="${
getPrivateMsg ? 'private' : 'public'
}" data-chat-peer="${conversationPeer}" data-msg-id="${normalizedMsgId}">
<img class="msg-img" id="${msgAvatarTmpId}" />
<div class=${msgBubble}>
<div class="msg-info">
<div class="msg-info-name">${getFrom}</div>
<div class="msg-info-time">${time}</div>
</div>
<div class="msg-text">
<span id="message-${chatMessagesId}"></span>
<hr/>
<div class="msg-footer">
<div class="msg-actions">
`;
msgHTML += `
let messageActionsHTML = `
<button
id="msg-delete-${chatMessagesId}"
class="${className.trash}"
@@ -10839,7 +10837,7 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '')
style="color:#fff; border:none; background:transparent;"
onclick="copyToClipboard('message-${chatMessagesId}')"
></button>`;
msgHTML += `
messageActionsHTML += `
<button
id="msg-reaction-${chatMessagesId}"
class="reaction-toggle-btn"
@@ -10847,7 +10845,7 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '')
onclick="toggleReactionPicker('${normalizedMsgId}', this)"
>😊</button>`;
if (isSpeechSynthesisSupported) {
msgHTML += `
messageActionsHTML += `
<button
id="msg-speech-${chatMessagesId}"
class="${className.speech}"
@@ -10855,14 +10853,26 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '')
onclick="speechElementText(false, '${getFrom}', 'message-${chatMessagesId}')"
></button>`;
}
msgHTML += `
</div>
<div class="message-reactions"></div>
</div>
</div>
</div>
</div>
`;
const msgHTML = renderRoomTemplate('tpl-msger-chat-message', {
text: {
senderName: getFrom,
messageTime: time,
},
html: {
messageActions: messageActionsHTML,
},
attrs: {
messageContainerId: `msg-${chatMessagesId}`,
messageContainerClass: `msg ${getSide}-msg`,
chatType: getPrivateMsg ? 'private' : 'public',
chatPeer: conversationPeer,
messageId: normalizedMsgId,
messageAvatarTmpId: msgAvatarTmpId,
messageBubbleClass: msgBubble,
messageTextId: `message-${chatMessagesId}`,
},
});
msgerChat.insertAdjacentHTML('beforeend', msgHTML);
const msgAvatarEl = document.getElementById(msgAvatarTmpId);
@@ -11069,8 +11079,9 @@ function resolvePeerNameById(peerId = '') {
if (peerId === CHAT_GPT_PEER_ID) return CHAT_GPT_NAME;
const privateChatButton = getId(peerId + '_pMsgBtn');
if (privateChatButton?.value) {
return privateChatButton.value;
const privatePeerName = privateChatButton?.dataset?.value || privateChatButton?.getAttribute('data-value');
if (privatePeerName) {
return privatePeerName;
}
return allPeers[peerId]?.peer_name || '';
@@ -11104,31 +11115,22 @@ function ensureChatGPTConversationEntry() {
return;
}
const chatGPTEntry = `
<div id="${CHAT_GPT_PEER_ID}_pMsgDiv" class="msger-private-chat-entry" data-peer-name="${CHAT_GPT_NAME.toLowerCase()}">
<div
id="${CHAT_GPT_PEER_ID}_pMsgBtn"
class="msger-chat-item"
role="button"
tabindex="0"
data-value="${CHAT_GPT_NAME}"
data-peer-id="${CHAT_GPT_PEER_ID}"
title="${CHAT_GPT_NAME}"
>
<img
id="${CHAT_GPT_PEER_ID}_pMsgAvatar"
class="msger-chat-avatar"
src="${images.chatgpt}"
alt="${CHAT_GPT_NAME}"
/>
<span class="msger-chat-item-copy">
<strong>${CHAT_GPT_NAME}</strong>
<small>Ask anything</small>
</span>
<span id="${CHAT_GPT_PEER_ID}_pMsgBadge" class="msger-chat-unread-badge hidden">0</span>
</div>
</div>
`;
const chatGPTEntry = renderRoomTemplate('tpl-chatgpt-participant-entry', {
text: {
participantName: CHAT_GPT_NAME,
participantSubtitle: 'Ask anything',
},
attrs: {
participantName: CHAT_GPT_NAME,
entryId: `${CHAT_GPT_PEER_ID}_pMsgDiv`,
entryPeerName: CHAT_GPT_NAME.toLowerCase(),
buttonId: `${CHAT_GPT_PEER_ID}_pMsgBtn`,
participantPeerId: CHAT_GPT_PEER_ID,
avatarId: `${CHAT_GPT_PEER_ID}_pMsgAvatar`,
avatarSrc: images.chatgpt,
badgeId: `${CHAT_GPT_PEER_ID}_pMsgBadge`,
},
});
msgerCPList.insertAdjacentHTML('afterbegin', chatGPTEntry);
@@ -11471,28 +11473,33 @@ async function msgerAddPeers(peers) {
`;
}
const msgerPrivateDiv = `
<div id="${peer_id}_pMsgDiv" class="msger-private-chat-entry" data-peer-name="${peer_name.toLowerCase()}">
<div id="${peer_id}_pMsgBtn" class="msger-chat-item" role="button" tabindex="0" data-value="${peer_name}" data-peer-id="${peer_id}" title="${peer_name}">
<img id="${peer_id}_pMsgAvatar" class="msger-chat-avatar" src="${chatAvatar}" alt="${peer_name}" />
<span class="msger-chat-item-copy">
<strong>${peer_name}</strong>
<small>Open private conversation</small>
</span>
<span id="${peer_id}_pMsgBadge" class="msger-chat-unread-badge hidden">0</span>
<div id="${peer_id}_pDropdownMenu" class="dropdown-menu-custom msger-participant-dropdown">
<button id="${peer_id}_pDropdownToggle" class="dropdown-toggle" type="button">
<i class="fas fa-ellipsis-vertical"></i>
</button>
<ul id="${peer_id}_pDropdownMenuList" class="dropdown-menu-custom-list app-dropdown-menu msger-participant-dropdown-menu">
${dropdownOptions}
</ul>
</div>
</div>
</div>
`;
const msgerPrivateDiv = renderRoomTemplate('tpl-msger-private-entry', {
text: {
participantName: peer_name,
participantSubtitle: 'Open private conversation',
},
html: {
dropdownOptions,
},
attrs: {
participantName: peer_name,
entryId: `${peer_id}_pMsgDiv`,
entryPeerName: peer_name.toLowerCase(),
buttonId: `${peer_id}_pMsgBtn`,
participantPeerId: peer_id,
avatarTmpId: `${peer_id}_pMsgAvatar`,
badgeId: `${peer_id}_pMsgBadge`,
dropdownMenuId: `${peer_id}_pDropdownMenu`,
dropdownToggleId: `${peer_id}_pDropdownToggle`,
dropdownListId: `${peer_id}_pDropdownMenuList`,
},
});
msgerCPList.insertAdjacentHTML('beforeend', msgerPrivateDiv);
const participantAvatar = getId(`${peer_id}_pMsgAvatar`);
if (participantAvatar) {
participantAvatar.setAttribute('src', chatAvatar);
}
msgerCPList.scrollTop += 500;
const msgerPrivateBtn = getId(peer_id + '_pMsgBtn');
@@ -11988,17 +11995,11 @@ function emitMsg(from, fromAvatar, to, msg, privateMsg, id, msgId = '') {
function showAITypingIndicator(aiName) {
const existing = getId(`ai-typing-${aiName}`);
if (existing) return;
const typingHTML = `
<div id="ai-typing-${aiName}" class="msg left-msg">
<div class="ai-typing-indicator">
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
`;
const typingHTML = renderRoomTemplate('tpl-ai-typing-indicator', {
attrs: {
typingIndicatorId: `ai-typing-${aiName}`,
},
});
msgerChat.insertAdjacentHTML('beforeend', typingHTML);
msgerChat.scrollTop = msgerChat.scrollHeight;
}
@@ -14053,25 +14054,7 @@ function createStickyNote() {
Swal.fire({
background: swBg,
title: 'Create Sticky Note',
html: `
<div class="sticky-note-form">
<textarea id="stickyNoteText" class="sticky-note-textarea" rows="4" placeholder="Type your note here...">Note</textarea>
<div class="sticky-note-colors-row">
<div class="sticky-note-color-group">
<label for="stickyNoteColor" class="sticky-note-color-label">
<i class="fas fa-palette"></i> Background
</label>
<input id="stickyNoteColor" type="color" value="#FFEB3B" class="sticky-note-color-input">
</div>
<div class="sticky-note-color-group">
<label for="stickyNoteTextColor" class="sticky-note-color-label">
<i class="fas fa-font"></i> Text
</label>
<input id="stickyNoteTextColor" type="color" value="#000000" class="sticky-note-color-input">
</div>
</div>
</div>
`,
html: renderRoomTemplate('tpl-sticky-note-form'),
showCancelButton: true,
confirmButtonText: 'Create',
cancelButtonText: 'Cancel',
@@ -14198,25 +14181,13 @@ async function openFilePickerModal(config) {
position: 'center',
title: title,
input: 'file',
html: `
<div class="mirotalk-file-picker">
<button type="button" id="mirotalkFileDropzone" class="mirotalk-file-dropzone">
<span class="mirotalk-file-dropzone-icon"><i class="fas fa-cloud-upload-alt"></i></span>
<span id="mirotalkFileDropzoneTitle" class="mirotalk-file-dropzone-title">${emptyStateTitle}</span>
<span id="mirotalkFileDropzoneSubtitle" class="mirotalk-file-dropzone-subtitle">${emptyStateSubtitle}</span>
<span class="mirotalk-file-dropzone-helper">${helperText}</span>
<span id="mirotalkFileBrowseBtn" class="mirotalk-file-dropzone-cta">Browse files</span>
</button>
<div id="mirotalkFilePreview" class="mirotalk-file-preview" hidden>
<div class="mirotalk-file-preview-icon"><i class="fas fa-file-alt"></i></div>
<div class="mirotalk-file-preview-meta">
<strong id="mirotalkFileName">No file selected</strong>
<span id="mirotalkFileDetails"></span>
</div>
<button type="button" id="mirotalkFileRemoveBtn" class="mirotalk-file-preview-remove">Remove</button>
</div>
</div>
`,
html: renderRoomTemplate('tpl-file-picker-modal', {
text: {
emptyStateTitle,
emptyStateSubtitle,
helperText,
},
}),
inputAttributes: {
accept: accept,
'aria-label': title,
@@ -15742,11 +15713,11 @@ function handleKickedOut(config) {
position: 'center',
imageUrl: images.leave,
title: 'Kicked out!',
html:
`<h2 style="color: #FF2D00;">` +
`User ` +
peer_name +
`</h2> will kick out you after <b style="color: #FF2D00;"></b> milliseconds.`,
html: renderRoomTemplate('tpl-kicked-out-modal', {
text: {
peerName: peer_name,
},
}),
timer: 5000,
timerProgressBar: true,
didOpen: () => {
@@ -15776,50 +15747,19 @@ function handleKickedOut(config) {
function showAbout() {
playSound('newMessage');
const aboutHtml = brand.about.html;
Swal.fire({
background: swBg,
position: 'center',
title: brand.about?.title && brand.about.title.trim() !== '' ? brand.about.title : 'WebRTC P2P v1.8.28',
title: brand.about?.title && brand.about.title.trim() !== '' ? brand.about.title : 'WebRTC P2P v1.8.30',
imageUrl: brand.about?.imageUrl && brand.about.imageUrl.trim() !== '' ? brand.about.imageUrl : images.about,
customClass: { image: 'img-about' },
html: `
<br/>
<div id="about">
${
brand.about?.html && brand.about.html.trim() !== ''
? brand.about.html
: `
<button
id="support-button"
data-umami-event="Support button"
onclick="window.open('https://codecanyon.net/user/miroslavpejic85', '_blank')">
<i class="${className.heart}"></i>&nbsp;Support
</button>
<br /><br /><br />
Author:
<a
id="linkedin-button"
data-umami-event="Linkedin button"
href="https://www.linkedin.com/in/miroslav-pejic-976a07101/"
target="_blank">
Miroslav Pejic
</a>
<br /><br />
Email:
<a
id="email-button"
data-umami-event="Email button"
href="mailto:miroslav.pejic.85@gmail.com?subject=MiroTalk P2P info">
miroslav.pejic.85@gmail.com
</a>
<br /><br />
<hr />
<span>&copy; 2025 MiroTalk P2P, all rights reserved</span>
<hr />
`
}
</div>
`,
html: renderRoomTemplate('tpl-about-modal', {
html: {
aboutHtml,
},
}),
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
});
@@ -16942,3 +16882,50 @@ function displayElements(elements) {
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Render HTML template with provided data
* @param {string} templateId - ID of the <template> element
* @param {object} data - Data to populate the template
* @param {object} data.text - Key-value pairs for text content (data-template-text)
* @param {object} data.html - Key-value pairs for HTML content (data-template-html
* @param {object} data.attrs - Key-value pairs for attributes (data-template-attr-*)
* @returns {string} Rendered HTML string
*/
function renderRoomTemplate(templateId, { text = {}, html = {}, attrs = {} } = {}) {
const template = document.getElementById(templateId);
if (!template || !template.content) return '';
const wrapper = document.createElement('div');
wrapper.appendChild(template.content.cloneNode(true));
wrapper.querySelectorAll('*').forEach((element) => {
element.getAttributeNames().forEach((name) => {
if (!name.startsWith('data-template-attr-')) return;
const attrName = name.replace('data-template-attr-', '');
const key = element.getAttribute(name);
const value = attrs[key];
if (value === undefined || value === null) {
element.removeAttribute(attrName);
} else {
element.setAttribute(attrName, value);
}
element.removeAttribute(name);
});
});
wrapper.querySelectorAll('[data-template-text]').forEach((element) => {
const key = element.getAttribute('data-template-text');
element.textContent = text[key] ?? '';
});
wrapper.querySelectorAll('[data-template-html]').forEach((element) => {
const key = element.getAttribute('data-template-html');
element.innerHTML = html[key] ?? '';
});
return wrapper.innerHTML.trim();
}
+226
View File
@@ -1568,6 +1568,232 @@ access to use this app.
<!-- End Video container -->
<!-- Start dynamic room templates -->
<template id="tpl-caption-message">
<div class="msg left-msg">
<img class="msg-img" data-template-attr-id="captionAvatarTmpId" />
<div class="msg-caption-bubble">
<div class="msg-info">
<div class="msg-info-name" data-template-text="captionInfoText"></div>
</div>
<div class="msg-text" data-template-text="captionText"></div>
</div>
</div>
</template>
<template id="tpl-msger-chat-message">
<div
data-template-attr-id="messageContainerId"
data-template-attr-class="messageContainerClass"
data-template-attr-data-sender="senderName"
data-template-attr-data-chat-type="chatType"
data-template-attr-data-chat-peer="chatPeer"
data-template-attr-data-msg-id="messageId"
>
<img class="msg-img" data-template-attr-id="messageAvatarTmpId" />
<div data-template-attr-class="messageBubbleClass">
<div class="msg-info">
<div class="msg-info-name" data-template-text="senderName"></div>
<div class="msg-info-time" data-template-text="messageTime"></div>
</div>
<div class="msg-text">
<span data-template-attr-id="messageTextId"></span>
<hr />
<div class="msg-footer">
<div class="msg-actions" data-template-html="messageActions"></div>
<div class="message-reactions"></div>
</div>
</div>
</div>
</div>
</template>
<template id="tpl-ai-typing-indicator">
<div data-template-attr-id="typingIndicatorId" class="msg left-msg">
<div class="ai-typing-indicator">
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</template>
<template id="tpl-chatgpt-participant-entry">
<div
data-template-attr-id="entryId"
class="msger-private-chat-entry"
data-template-attr-data-peer-name="entryPeerName"
>
<div
data-template-attr-id="buttonId"
class="msger-chat-item"
role="button"
tabindex="0"
data-template-attr-data-value="participantName"
data-template-attr-data-peer-id="participantPeerId"
data-template-attr-title="participantName"
>
<img
data-template-attr-id="avatarId"
class="msger-chat-avatar"
data-template-attr-src="avatarSrc"
data-template-attr-alt="participantName"
/>
<span class="msger-chat-item-copy">
<strong data-template-text="participantName"></strong>
<small data-template-text="participantSubtitle"></small>
</span>
<span data-template-attr-id="badgeId" class="msger-chat-unread-badge hidden">0</span>
</div>
</div>
</template>
<template id="tpl-msger-private-entry">
<div
data-template-attr-id="entryId"
class="msger-private-chat-entry"
data-template-attr-data-peer-name="entryPeerName"
>
<div
data-template-attr-id="buttonId"
class="msger-chat-item"
role="button"
tabindex="0"
data-template-attr-data-value="participantName"
data-template-attr-data-peer-id="participantPeerId"
data-template-attr-title="participantName"
>
<img
data-template-attr-id="avatarTmpId"
class="msger-chat-avatar"
data-template-attr-alt="participantName"
/>
<span class="msger-chat-item-copy">
<strong data-template-text="participantName"></strong>
<small data-template-text="participantSubtitle"></small>
</span>
<span data-template-attr-id="badgeId" class="msger-chat-unread-badge hidden">0</span>
<div data-template-attr-id="dropdownMenuId" class="dropdown-menu-custom msger-participant-dropdown">
<button data-template-attr-id="dropdownToggleId" class="dropdown-toggle" type="button">
<i class="fas fa-ellipsis-vertical"></i>
</button>
<ul
data-template-attr-id="dropdownListId"
class="dropdown-menu-custom-list app-dropdown-menu msger-participant-dropdown-menu"
data-template-html="dropdownOptions"
></ul>
</div>
</div>
</div>
</template>
<template id="tpl-share-room-modal">
<div id="qrRoomContainer">
<canvas id="qrRoom"></canvas>
</div>
<br />
<p class="share-room-modal-highlight">Join from your mobile device</p>
<p class="share-room-modal-description">
No need for apps, simply capture the QR code with your mobile camera Or Invite someone else to join by
sending them the following URL
</p>
<p class="share-room-modal-highlight" data-template-text="roomURL"></p>
</template>
<template id="tpl-sticky-note-form">
<div class="sticky-note-form">
<textarea
id="stickyNoteText"
class="sticky-note-textarea"
rows="4"
placeholder="Type your note here..."
>
Note</textarea
>
<div class="sticky-note-colors-row">
<div class="sticky-note-color-group">
<label for="stickyNoteColor" class="sticky-note-color-label">
<i class="fas fa-palette"></i> Background
</label>
<input id="stickyNoteColor" type="color" value="#FFEB3B" class="sticky-note-color-input" />
</div>
<div class="sticky-note-color-group">
<label for="stickyNoteTextColor" class="sticky-note-color-label">
<i class="fas fa-font"></i> Text
</label>
<input id="stickyNoteTextColor" type="color" value="#000000" class="sticky-note-color-input" />
</div>
</div>
</div>
</template>
<template id="tpl-file-picker-modal">
<div class="mirotalk-file-picker">
<button type="button" id="mirotalkFileDropzone" class="mirotalk-file-dropzone">
<span class="mirotalk-file-dropzone-icon"><i class="fas fa-cloud-upload-alt"></i></span>
<span
id="mirotalkFileDropzoneTitle"
class="mirotalk-file-dropzone-title"
data-template-text="emptyStateTitle"
></span>
<span
id="mirotalkFileDropzoneSubtitle"
class="mirotalk-file-dropzone-subtitle"
data-template-text="emptyStateSubtitle"
></span>
<span class="mirotalk-file-dropzone-helper" data-template-text="helperText"></span>
<span id="mirotalkFileBrowseBtn" class="mirotalk-file-dropzone-cta">Browse files</span>
</button>
<div id="mirotalkFilePreview" class="mirotalk-file-preview" hidden>
<div class="mirotalk-file-preview-icon"><i class="fas fa-file-alt"></i></div>
<div class="mirotalk-file-preview-meta">
<strong id="mirotalkFileName">No file selected</strong>
<span id="mirotalkFileDetails"></span>
</div>
<button type="button" id="mirotalkFileRemoveBtn" class="mirotalk-file-preview-remove">
Remove
</button>
</div>
</div>
</template>
<template id="tpl-kicked-out-modal">
<h2 class="kicked-out-modal-alert-title">User <span data-template-text="peerName"></span></h2>
will kick out you after <b class="kicked-out-modal-alert-time"></b> milliseconds.
</template>
<template id="tpl-about-modal">
<br />
<div id="about" data-template-html="aboutHtml"></div>
</template>
<template id="tpl-extra-info-grid">
<div class="extra-info-grid" data-template-html="rows"></div>
</template>
<template id="tpl-theme-card-content">
<i data-template-attr-class="iconClass"></i><span data-template-text="label"></span>
</template>
<template id="tpl-last-recording-info">
<br />Last recording info: <span data-template-html="recordingInfo"></span>
</template>
<template id="tpl-room-busy-message">
The room is limited to <span data-template-text="maxUsers"></span> users. <br />
Please try again later
</template>
<template id="tpl-username-in-use-message">
The Username is already in use. <br />
Please try with another one
</template>
<!-- End dynamic room templates -->
<!--
- JS scripts https://cdn.jsdelivr.net
+120
View File
@@ -0,0 +1,120 @@
'use strict';
require('should');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { JSDOM } = require('jsdom');
function extractNamedFunction(source, functionName) {
const signature = `function ${functionName}`;
const start = source.indexOf(signature);
if (start === -1) {
throw new Error(`Unable to find ${functionName} in source file`);
}
const paramsStart = source.indexOf('(', start);
let paramsDepth = 0;
let bodyStart = -1;
for (let index = paramsStart; index < source.length; index++) {
const char = source[index];
if (char === '(') paramsDepth++;
if (char === ')') paramsDepth--;
if (paramsDepth === 0) {
bodyStart = source.indexOf('{', index);
break;
}
}
if (bodyStart === -1) {
throw new Error(`Unable to find ${functionName} body in source file`);
}
let depth = 0;
for (let index = bodyStart; index < source.length; index++) {
const char = source[index];
if (char === '{') depth++;
if (char === '}') depth--;
if (depth === 0) {
return source.slice(start, index + 1);
}
}
throw new Error(`Unable to parse ${functionName} from source file`);
}
function loadNamedFunctionSource(functionName, relativePaths) {
for (const relativePath of relativePaths) {
const sourcePath = path.join(__dirname, '..', ...relativePath.split('/'));
const source = fs.readFileSync(sourcePath, 'utf8');
try {
return extractNamedFunction(source, functionName);
} catch (error) {
if (!error.message.includes(`Unable to find ${functionName}`)) {
throw error;
}
}
}
throw new Error(`Unable to find ${functionName} in source file`);
}
describe('test-RoomTemplates', () => {
let renderRoomTemplate;
let document;
beforeEach(() => {
const dom = new JSDOM('<!doctype html><html><body></body></html>');
document = dom.window.document;
const functionSource = loadNamedFunctionSource('renderRoomTemplate', ['public/js/client.js']);
renderRoomTemplate = vm.runInNewContext(`(${functionSource})`, { document });
});
it('preserves empty string attributes used by placeholder options', () => {
const template = document.createElement('template');
template.id = 'breakoutRoomOptionTemplate';
template.innerHTML = '<option data-template-attr-value="value" data-template-text="label"></option>';
document.body.appendChild(template);
const rendered = renderRoomTemplate('breakoutRoomOptionTemplate', {
text: { label: 'Not assigned' },
attrs: { value: '' },
});
const select = document.createElement('select');
select.innerHTML = rendered;
select.value.should.equal('');
select.options.length.should.equal(1);
select.options[0].textContent.should.equal('Not assigned');
select.options[0].getAttribute('value').should.equal('');
});
it('still renders non-empty attributes normally', () => {
const template = document.createElement('template');
template.id = 'participantTemplate';
template.innerHTML = '<div data-template-attr-data-peer-id="peerId" data-template-text="peerName"></div>';
document.body.appendChild(template);
const rendered = renderRoomTemplate('participantTemplate', {
text: { peerName: 'Alice' },
attrs: { peerId: 'peer-1' },
});
const wrapper = document.createElement('div');
wrapper.innerHTML = rendered;
wrapper.firstElementChild.textContent.should.equal('Alice');
wrapper.firstElementChild.getAttribute('data-peer-id').should.equal('peer-1');
});
});