Merge pull request #335 from renatospirito17/local-profile-avatar

Local profile avatar
This commit is contained in:
Miroslav Pejic
2026-04-28 10:26:53 +02:00
committed by GitHub
30 changed files with 259 additions and 11 deletions
+2
View File
@@ -180,6 +180,8 @@ To set up your own instance of `MiroTalk P2P` on a dedicated cloud server, pleas
- ianramzy (html [template](https://cruip.com/demos/neon/))
- vasanthv (webrtc-logic)
- fabric.js (whiteboard)
- [Robohash.org](https://robohash.org) (random avatars)
- [Image by ddraw on Freepik](https://www.freepik.com/free-vector/collection-female-male-avatars_1105371.htm) (avatar illustrations)
</details>
+4 -2
View File
@@ -1616,17 +1616,19 @@ io.sockets.on('connect', async (socket) => {
let peer_id_to_update = null;
for (let peer_id in peers[room_id]) {
if (peers[room_id][peer_id]['peer_name'] == peer_name_old && peer_id == socket.id) {
if (peer_id == socket.id) {
peers[room_id][peer_id]['peer_name'] = peer_name_new;
peers[room_id][peer_id]['peer_avatar'] = peer_avatar;
// presenter
if (presenters && presenters[room_id] && presenters[room_id][peer_id]) {
presenters[room_id][peer_id]['peer_name'] = peer_name_new;
}
peer_id_to_update = peer_id;
log.debug('[' + socket.id + '] Peer name changed', {
log.debug('[' + socket.id + '] Peer profile changed', {
peer_name_old: peer_name_old,
peer_name_new: peer_name_new,
});
break;
}
}
+9
View File
@@ -3161,6 +3161,8 @@ button {
#activeRoomsBtn,
#myPeerNameSetBtn,
#myProfileAvatarUploadBtn,
#myProfileAvatarResetBtn,
#captionEveryoneBtn,
#muteEveryoneBtn,
#hideEveryoneBtn,
@@ -3177,6 +3179,8 @@ button {
}
#myPeerNameSetBtn:hover,
#myProfileAvatarUploadBtn:hover,
#myProfileAvatarResetBtn:hover,
#roomSendEmailBtn:hover,
#lockRoomBtn:hover,
#unlockRoomBtn:hover {
@@ -3220,6 +3224,11 @@ button {
transition: all 0.3s ease-in-out;
}
#myProfileAvatarUploadBtn,
#myProfileAvatarResetBtn {
margin-top: 6px;
}
/*--------------------------------------------------------------
# Settings Table
--------------------------------------------------------------*/
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+236 -9
View File
@@ -360,6 +360,8 @@ const tabLanguagesBtn = getId('tabLanguagesBtn');
const mySettingsCloseBtn = getId('mySettingsCloseBtn');
const myPeerNameSet = getId('myPeerNameSet');
const myPeerNameSetBtn = getId('myPeerNameSetBtn');
const myProfileAvatarUploadBtn = getId('myProfileAvatarUploadBtn');
const myProfileAvatarResetBtn = getId('myProfileAvatarResetBtn');
const switchSounds = getId('switchSounds');
const switchShare = getId('switchShare');
const switchKeepButtonsVisible = getId('switchKeepButtonsVisible');
@@ -563,6 +565,7 @@ let thisMaxRoomParticipants = 8;
let swBg = 'rgba(0, 0, 0, 0.7)'; // swAlert background color
let isDocumentOnFullScreen = false;
let isToggleExtraBtnClicked = false;
let hasTemporaryAvatar = false;
// peer
let myPeerId; // This socket.id
@@ -847,6 +850,8 @@ function setButtonsToolTip() {
// Settings
setTippy(mySettingsCloseBtn, 'Close', 'bottom');
setTippy(myPeerNameSetBtn, 'Change name', 'top');
setTippy(myProfileAvatarUploadBtn, 'Set temporary avatar URL', 'top');
setTippy(myProfileAvatarResetBtn, 'Reset temporary avatar', 'top');
setTippy(myRoomId, 'Room name (click to copy/share)', 'right');
setTippy(mySessionTime, 'Session time', 'right');
setTippy(
@@ -1237,10 +1242,11 @@ function generateRandomName() {
function getPeerAvatar() {
const avatar = getQueryParam('avatar');
const avatarDisabled = avatar === '0' || avatar === 'false';
const isBase64Avatar = typeof avatar === 'string' && avatar.startsWith('data:image/');
console.log('Direct join', { avatar: avatar });
if (avatarDisabled || !isImageURL(avatar)) {
if (avatarDisabled || isBase64Avatar || !isValidAvatarURL(avatar)) {
return false;
}
return avatar;
@@ -2633,6 +2639,10 @@ async function handleAddPeer(config) {
return;
}
// Re-broadcast current profile to ensure late joiners receive latest avatar/name.
// This uses the existing peerName signaling path.
emitMyPeerProfile();
console.log('iceServers', iceServers[0]);
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection
@@ -2695,6 +2705,18 @@ async function handleAddPeer(config) {
screenReaderAccessibility.announceMessage(`${peer_name} joined the room`);
}
/**
* Broadcast my current profile (name + avatar) to room peers
*/
function emitMyPeerProfile() {
sendToServer('peerName', {
room_id: roomId,
peer_name_old: myPeerName,
peer_name_new: myPeerName,
peer_avatar: myPeerAvatar,
});
}
/**
* Handle peers connection state
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event
@@ -5259,10 +5281,11 @@ function genAvatarSvg(peerName, avatarImgSize) {
*/
function setPeerAvatarImgName(videoAvatarImageId, peerName, peerAvatar) {
const videoAvatarImageElement = getId(videoAvatarImageId);
if (!videoAvatarImageElement) return;
videoAvatarImageElement.style.pointerEvents = 'none';
// If a valid avatar image URL is provided
if (peerAvatar && isImageURL(peerAvatar)) {
if (peerAvatar && isValidAvatarURL(peerAvatar)) {
videoAvatarImageElement.setAttribute('src', peerAvatar);
}
// If not, use SVG based on the email validity
@@ -5285,7 +5308,7 @@ function setPeerAvatarImgName(videoAvatarImageId, peerName, peerAvatar) {
*/
function setPeerChatAvatarImgName(avatar, peerName, peerAvatar) {
const avatarImg =
peerAvatar && isImageURL(peerAvatar)
peerAvatar && isValidAvatarURL(peerAvatar)
? peerAvatar
: isValidEmail(peerName)
? genGravatar(peerName)
@@ -7121,6 +7144,13 @@ function setMySettingsBtn() {
myPeerNameSetBtn.addEventListener('click', (e) => {
updateMyPeerName();
});
myProfileAvatarUploadBtn.addEventListener('click', async () => {
await updateMyPeerAvatarByUrl();
});
myProfileAvatarResetBtn.addEventListener('click', () => {
resetMyPeerAvatarInMemory();
});
updateMyAvatarResetButtonVisibility();
// Sounds
switchSounds.addEventListener('change', (e) => {
notifyBySound = e.currentTarget.checked;
@@ -10569,7 +10599,7 @@ function handleSpeechTranscript(config) {
const time_stamp = getFormatDate(new Date());
const avatar_image =
peer_avatar && isImageURL(peer_avatar)
peer_avatar && isValidAvatarURL(peer_avatar)
? peer_avatar
: isValidEmail(peer_name)
? genGravatar(peer_name)
@@ -10577,9 +10607,14 @@ function handleSpeechTranscript(config) {
if (!isCaptionBoxVisible && transcriptShowOnMsg) showCaptionDraggable();
// avatar_image is a user-controlled URL; do NOT interpolate it into
// insertAdjacentHTML — filterXSS encodes " to &quot; which the HTML
// 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" src="${avatar_image}" />
<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>
@@ -10589,6 +10624,11 @@ function handleSpeechTranscript(config) {
</div>
`;
captionChat.insertAdjacentHTML('beforeend', msgHTML);
const captionAvatarEl = document.getElementById(captionAvatarTmpId);
if (captionAvatarEl) {
captionAvatarEl.setAttribute('src', avatar_image);
captionAvatarEl.removeAttribute('id');
}
captionChat.scrollTop += 500;
transcripts.push({
time: time_stamp,
@@ -10652,11 +10692,14 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '')
// check if i receive a private message
let msgBubble = getPrivateMsg ? 'private-msg-bubble' : 'msg-bubble';
// 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" src="${getImg}" />
<img class="msg-img" id="${msgAvatarTmpId}" />
<div class=${msgBubble}>
<div class="msg-info">
<div class="msg-info-name">${getFrom}</div>
@@ -10707,6 +10750,11 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '')
`;
msgerChat.insertAdjacentHTML('beforeend', msgHTML);
const msgAvatarEl = document.getElementById(msgAvatarTmpId);
if (msgAvatarEl) {
msgAvatarEl.setAttribute('src', getImg);
msgAvatarEl.removeAttribute('id');
}
const message = getId(`message-${chatMessagesId}`);
if (message) {
@@ -11280,7 +11328,7 @@ async function msgerAddPeers(peers) {
// if there isn't add it....
if (!exsistMsgerPrivateDiv) {
const chatAvatar =
peer_avatar && isImageURL(peer_avatar)
peer_avatar && isValidAvatarURL(peer_avatar)
? peer_avatar
: isValidEmail(peer_name)
? genGravatar(peer_name)
@@ -11634,6 +11682,8 @@ function isValidHttpURL(input) {
*/
function isImageURL(input) {
if (!input || typeof input !== 'string') return false;
// Data URLs can still be valid images for generic content handling.
if (input.startsWith('data:image/')) return true;
try {
const url = new URL(input);
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg'].some((ext) =>
@@ -11644,6 +11694,24 @@ function isImageURL(input) {
}
}
/**
* Check if a URL is a valid HTTP/HTTPS avatar URL.
* Unlike isImageURL, this does NOT require a file extension,
* so it accepts dynamic avatar endpoints (e.g. GitHub, Gravatar, Robohash).
* @param {string} input
* @returns {boolean}
*/
function isValidAvatarURL(input) {
if (!input || typeof input !== 'string') return false;
if (input.startsWith('data:')) return false;
try {
const url = new URL(input);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
/**
* Check if Image File
* @return boolean
@@ -12075,12 +12143,171 @@ async function updateMyPeerName() {
userLog('toast', 'My name changed to ' + myPeerName);
}
/**
* Update my avatar from URL in-memory only (cleared on page refresh)
*/
async function updateMyPeerAvatarByUrl() {
const result = await Swal.fire({
background: swBg,
title: 'Set avatar URL',
input: 'url',
inputLabel: 'Public image URL',
inputPlaceholder: 'https://example.com/avatar.jpg',
confirmButtonText: 'Apply',
showCancelButton: true,
showClass: { popup: 'animate__animated animate__fadeInDown' },
hideClass: { popup: 'animate__animated animate__fadeOutUp' },
inputValidator: (value) => {
if (!value) return 'Please enter an image URL';
if (value.startsWith('data:')) return 'Base64 avatars are not supported';
if (!isValidAvatarURL(value)) return 'Only http/https URLs are supported';
return null;
},
preConfirm: (url) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(url);
img.onerror = () => {
Swal.showValidationMessage(
'Could not load the image — the URL may be invalid, restricted, or not an image'
);
resolve(false); // keep dialog open
};
img.src = url;
}),
didOpen: () => {
const input = document.querySelector('.swal2-input');
if (!input) return;
const preview = document.createElement('img');
preview.style.cssText =
'display:none;width:72px;height:72px;border-radius:50%;object-fit:cover;border:2px solid #4caf50;margin:8px auto 4px;';
input.parentNode.insertBefore(preview, input);
function updatePreview(url) {
if (!url) {
preview.style.display = 'none';
return;
}
preview.src = url;
preview.style.display = 'block';
}
input.addEventListener('input', (e) => updatePreview(e.target.value.trim()));
function makeAvatarImg(url) {
const img = document.createElement('img');
img.src = url;
img.title = 'Click to use this avatar';
img.style.cssText =
'width:48px;height:48px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s;object-fit:cover;background:#222;flex-shrink:0;';
img.addEventListener('mouseover', () => (img.style.borderColor = '#4caf50'));
img.addEventListener('mouseout', () => (img.style.borderColor = 'transparent'));
img.addEventListener('click', () => {
input.value = url;
input.dispatchEvent(new Event('input'));
updatePreview(url);
});
return img;
}
// Self-hosted avatars
const localLabel = document.createElement('p');
localLabel.textContent = 'Pick an avatar:';
localLabel.style.cssText = 'color:#aaa;font-size:12px;margin:10px 0 6px;text-align:center;';
const localGrid = document.createElement('div');
localGrid.style.cssText =
'display:flex;flex-wrap:wrap;justify-content:center;gap:8px;max-height:120px;overflow-y:scroll;-webkit-overflow-scrolling:touch;touch-action:pan-y;padding:4px 2px;margin-bottom:4px;';
localGrid.addEventListener('touchmove', (e) => e.stopPropagation(), { passive: true });
for (let i = 1; i <= 25; i++) {
const url = `${window.location.origin}/images/avatars/avatar_${String(i).padStart(2, '0')}.png`;
localGrid.appendChild(makeAvatarImg(url));
}
// Robohash random avatars
const roboLabel = document.createElement('p');
roboLabel.textContent = 'Or pick a random avatar:';
roboLabel.style.cssText = 'color:#aaa;font-size:12px;margin:10px 0 6px;text-align:center;';
const roboGrid = document.createElement('div');
roboGrid.style.cssText = 'display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-bottom:4px;';
for (let i = 0; i < 6; i++) {
const seed = Math.random().toString(36).substring(2, 10);
const url = `https://robohash.org/${seed}.png`;
roboGrid.appendChild(makeAvatarImg(url));
}
let insertAfter = input;
for (const el of [localLabel, localGrid, roboLabel, roboGrid]) {
insertAfter.parentNode.insertBefore(el, insertAfter.nextSibling);
insertAfter = el;
}
},
});
if (!result.isConfirmed || !result.value) return;
try {
myPeerAvatar = result.value;
hasTemporaryAvatar = true;
setPeerAvatarImgName('myVideoAvatarImage', myPeerName, myPeerAvatar);
setPeerAvatarImgName('myProfileAvatar', myPeerName, myPeerAvatar);
setPeerChatAvatarImgName('right', myPeerName, myPeerAvatar);
updateMyAvatarResetButtonVisibility();
emitMyPeerProfile();
userLog('toast', 'Temporary avatar applied (will reset on refresh)');
} catch (err) {
console.error('Failed to set avatar URL', err);
userLog('error', 'Unable to apply avatar URL');
}
}
/**
* Reset in-memory avatar to default generated/fallback avatar
*/
function resetMyPeerAvatarInMemory() {
myPeerAvatar = false;
hasTemporaryAvatar = false;
setPeerAvatarImgName('myVideoAvatarImage', myPeerName, myPeerAvatar);
setPeerAvatarImgName('myProfileAvatar', myPeerName, myPeerAvatar);
setPeerChatAvatarImgName('right', myPeerName, myPeerAvatar);
updateMyAvatarResetButtonVisibility();
emitMyPeerProfile();
userLog('toast', 'Temporary avatar reset');
}
/**
* Show reset avatar button only for uploaded temporary avatars
*/
function updateMyAvatarResetButtonVisibility() {
if (!myProfileAvatarResetBtn) return;
myProfileAvatarResetBtn.classList.toggle('hidden', !hasTemporaryAvatar);
if (myProfileAvatarUploadBtn) myProfileAvatarUploadBtn.classList.toggle('hidden', hasTemporaryAvatar);
}
/**
* Append updated peer name to video player
* @param {object} config data
*/
function handlePeerName(config) {
const { peer_id, peer_name, peer_avatar } = config;
const peer_id = config.peer_id;
const peer_name = filterXSS(config.peer_name);
const peer_avatar = filterXSS(config.peer_avatar);
// Keep the latest profile in memory so late DOM creation still uses updated data.
if (allPeers && allPeers[peer_id]) {
allPeers[peer_id]['peer_name'] = peer_name;
allPeers[peer_id]['peer_avatar'] = peer_avatar;
}
const videoName = getId(peer_id + '_name');
const screenName = getId(peer_id + '_screen_name');
if (videoName) videoName.innerText = peer_name;
@@ -12092,7 +12319,7 @@ function handlePeerName(config) {
if (msgerPeerAvatar) {
msgerPeerAvatar.src =
peer_avatar && isImageURL(peer_avatar)
peer_avatar && isValidAvatarURL(peer_avatar)
? peer_avatar
: isValidEmail(peer_name)
? genGravatar(peer_name)
+8
View File
@@ -1048,6 +1048,14 @@ access to use this app.
<div>
<img id="myProfileAvatar" />
</div>
<div>
<button id="myProfileAvatarUploadBtn">
<i class="fas fa-image"></i>&nbsp;Set avatar URL
</button>
<button id="myProfileAvatarResetBtn" class="hidden">
<i class="fas fa-rotate-left"></i>&nbsp;Reset avatar
</button>
</div>
<br />
<div class="title">
<i class="fas fa-user"></i>