Merge pull request #335 from renatospirito17/local-profile-avatar
Local profile avatar
@@ -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/))
|
- ianramzy (html [template](https://cruip.com/demos/neon/))
|
||||||
- vasanthv (webrtc-logic)
|
- vasanthv (webrtc-logic)
|
||||||
- fabric.js (whiteboard)
|
- 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>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -1616,17 +1616,19 @@ io.sockets.on('connect', async (socket) => {
|
|||||||
let peer_id_to_update = null;
|
let peer_id_to_update = null;
|
||||||
|
|
||||||
for (let peer_id in peers[room_id]) {
|
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_name'] = peer_name_new;
|
||||||
|
peers[room_id][peer_id]['peer_avatar'] = peer_avatar;
|
||||||
// presenter
|
// presenter
|
||||||
if (presenters && presenters[room_id] && presenters[room_id][peer_id]) {
|
if (presenters && presenters[room_id] && presenters[room_id][peer_id]) {
|
||||||
presenters[room_id][peer_id]['peer_name'] = peer_name_new;
|
presenters[room_id][peer_id]['peer_name'] = peer_name_new;
|
||||||
}
|
}
|
||||||
peer_id_to_update = peer_id;
|
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_old: peer_name_old,
|
||||||
peer_name_new: peer_name_new,
|
peer_name_new: peer_name_new,
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3161,6 +3161,8 @@ button {
|
|||||||
|
|
||||||
#activeRoomsBtn,
|
#activeRoomsBtn,
|
||||||
#myPeerNameSetBtn,
|
#myPeerNameSetBtn,
|
||||||
|
#myProfileAvatarUploadBtn,
|
||||||
|
#myProfileAvatarResetBtn,
|
||||||
#captionEveryoneBtn,
|
#captionEveryoneBtn,
|
||||||
#muteEveryoneBtn,
|
#muteEveryoneBtn,
|
||||||
#hideEveryoneBtn,
|
#hideEveryoneBtn,
|
||||||
@@ -3177,6 +3179,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#myPeerNameSetBtn:hover,
|
#myPeerNameSetBtn:hover,
|
||||||
|
#myProfileAvatarUploadBtn:hover,
|
||||||
|
#myProfileAvatarResetBtn:hover,
|
||||||
#roomSendEmailBtn:hover,
|
#roomSendEmailBtn:hover,
|
||||||
#lockRoomBtn:hover,
|
#lockRoomBtn:hover,
|
||||||
#unlockRoomBtn:hover {
|
#unlockRoomBtn:hover {
|
||||||
@@ -3220,6 +3224,11 @@ button {
|
|||||||
transition: all 0.3s ease-in-out;
|
transition: all 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#myProfileAvatarUploadBtn,
|
||||||
|
#myProfileAvatarResetBtn {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/*--------------------------------------------------------------
|
/*--------------------------------------------------------------
|
||||||
# Settings Table
|
# Settings Table
|
||||||
--------------------------------------------------------------*/
|
--------------------------------------------------------------*/
|
||||||
|
|||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -360,6 +360,8 @@ const tabLanguagesBtn = getId('tabLanguagesBtn');
|
|||||||
const mySettingsCloseBtn = getId('mySettingsCloseBtn');
|
const mySettingsCloseBtn = getId('mySettingsCloseBtn');
|
||||||
const myPeerNameSet = getId('myPeerNameSet');
|
const myPeerNameSet = getId('myPeerNameSet');
|
||||||
const myPeerNameSetBtn = getId('myPeerNameSetBtn');
|
const myPeerNameSetBtn = getId('myPeerNameSetBtn');
|
||||||
|
const myProfileAvatarUploadBtn = getId('myProfileAvatarUploadBtn');
|
||||||
|
const myProfileAvatarResetBtn = getId('myProfileAvatarResetBtn');
|
||||||
const switchSounds = getId('switchSounds');
|
const switchSounds = getId('switchSounds');
|
||||||
const switchShare = getId('switchShare');
|
const switchShare = getId('switchShare');
|
||||||
const switchKeepButtonsVisible = getId('switchKeepButtonsVisible');
|
const switchKeepButtonsVisible = getId('switchKeepButtonsVisible');
|
||||||
@@ -563,6 +565,7 @@ let thisMaxRoomParticipants = 8;
|
|||||||
let swBg = 'rgba(0, 0, 0, 0.7)'; // swAlert background color
|
let swBg = 'rgba(0, 0, 0, 0.7)'; // swAlert background color
|
||||||
let isDocumentOnFullScreen = false;
|
let isDocumentOnFullScreen = false;
|
||||||
let isToggleExtraBtnClicked = false;
|
let isToggleExtraBtnClicked = false;
|
||||||
|
let hasTemporaryAvatar = false;
|
||||||
|
|
||||||
// peer
|
// peer
|
||||||
let myPeerId; // This socket.id
|
let myPeerId; // This socket.id
|
||||||
@@ -847,6 +850,8 @@ function setButtonsToolTip() {
|
|||||||
// Settings
|
// Settings
|
||||||
setTippy(mySettingsCloseBtn, 'Close', 'bottom');
|
setTippy(mySettingsCloseBtn, 'Close', 'bottom');
|
||||||
setTippy(myPeerNameSetBtn, 'Change name', 'top');
|
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(myRoomId, 'Room name (click to copy/share)', 'right');
|
||||||
setTippy(mySessionTime, 'Session time', 'right');
|
setTippy(mySessionTime, 'Session time', 'right');
|
||||||
setTippy(
|
setTippy(
|
||||||
@@ -1237,10 +1242,11 @@ function generateRandomName() {
|
|||||||
function getPeerAvatar() {
|
function getPeerAvatar() {
|
||||||
const avatar = getQueryParam('avatar');
|
const avatar = getQueryParam('avatar');
|
||||||
const avatarDisabled = avatar === '0' || avatar === 'false';
|
const avatarDisabled = avatar === '0' || avatar === 'false';
|
||||||
|
const isBase64Avatar = typeof avatar === 'string' && avatar.startsWith('data:image/');
|
||||||
|
|
||||||
console.log('Direct join', { avatar: avatar });
|
console.log('Direct join', { avatar: avatar });
|
||||||
|
|
||||||
if (avatarDisabled || !isImageURL(avatar)) {
|
if (avatarDisabled || isBase64Avatar || !isValidAvatarURL(avatar)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return avatar;
|
return avatar;
|
||||||
@@ -2633,6 +2639,10 @@ async function handleAddPeer(config) {
|
|||||||
return;
|
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]);
|
console.log('iceServers', iceServers[0]);
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection
|
// 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`);
|
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
|
* Handle peers connection state
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event
|
* 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) {
|
function setPeerAvatarImgName(videoAvatarImageId, peerName, peerAvatar) {
|
||||||
const videoAvatarImageElement = getId(videoAvatarImageId);
|
const videoAvatarImageElement = getId(videoAvatarImageId);
|
||||||
|
if (!videoAvatarImageElement) return;
|
||||||
videoAvatarImageElement.style.pointerEvents = 'none';
|
videoAvatarImageElement.style.pointerEvents = 'none';
|
||||||
|
|
||||||
// If a valid avatar image URL is provided
|
// If a valid avatar image URL is provided
|
||||||
if (peerAvatar && isImageURL(peerAvatar)) {
|
if (peerAvatar && isValidAvatarURL(peerAvatar)) {
|
||||||
videoAvatarImageElement.setAttribute('src', peerAvatar);
|
videoAvatarImageElement.setAttribute('src', peerAvatar);
|
||||||
}
|
}
|
||||||
// If not, use SVG based on the email validity
|
// If not, use SVG based on the email validity
|
||||||
@@ -5285,7 +5308,7 @@ function setPeerAvatarImgName(videoAvatarImageId, peerName, peerAvatar) {
|
|||||||
*/
|
*/
|
||||||
function setPeerChatAvatarImgName(avatar, peerName, peerAvatar) {
|
function setPeerChatAvatarImgName(avatar, peerName, peerAvatar) {
|
||||||
const avatarImg =
|
const avatarImg =
|
||||||
peerAvatar && isImageURL(peerAvatar)
|
peerAvatar && isValidAvatarURL(peerAvatar)
|
||||||
? peerAvatar
|
? peerAvatar
|
||||||
: isValidEmail(peerName)
|
: isValidEmail(peerName)
|
||||||
? genGravatar(peerName)
|
? genGravatar(peerName)
|
||||||
@@ -7121,6 +7144,13 @@ function setMySettingsBtn() {
|
|||||||
myPeerNameSetBtn.addEventListener('click', (e) => {
|
myPeerNameSetBtn.addEventListener('click', (e) => {
|
||||||
updateMyPeerName();
|
updateMyPeerName();
|
||||||
});
|
});
|
||||||
|
myProfileAvatarUploadBtn.addEventListener('click', async () => {
|
||||||
|
await updateMyPeerAvatarByUrl();
|
||||||
|
});
|
||||||
|
myProfileAvatarResetBtn.addEventListener('click', () => {
|
||||||
|
resetMyPeerAvatarInMemory();
|
||||||
|
});
|
||||||
|
updateMyAvatarResetButtonVisibility();
|
||||||
// Sounds
|
// Sounds
|
||||||
switchSounds.addEventListener('change', (e) => {
|
switchSounds.addEventListener('change', (e) => {
|
||||||
notifyBySound = e.currentTarget.checked;
|
notifyBySound = e.currentTarget.checked;
|
||||||
@@ -10569,7 +10599,7 @@ function handleSpeechTranscript(config) {
|
|||||||
const time_stamp = getFormatDate(new Date());
|
const time_stamp = getFormatDate(new Date());
|
||||||
|
|
||||||
const avatar_image =
|
const avatar_image =
|
||||||
peer_avatar && isImageURL(peer_avatar)
|
peer_avatar && isValidAvatarURL(peer_avatar)
|
||||||
? peer_avatar
|
? peer_avatar
|
||||||
: isValidEmail(peer_name)
|
: isValidEmail(peer_name)
|
||||||
? genGravatar(peer_name)
|
? genGravatar(peer_name)
|
||||||
@@ -10577,9 +10607,14 @@ function handleSpeechTranscript(config) {
|
|||||||
|
|
||||||
if (!isCaptionBoxVisible && transcriptShowOnMsg) showCaptionDraggable();
|
if (!isCaptionBoxVisible && transcriptShowOnMsg) showCaptionDraggable();
|
||||||
|
|
||||||
|
// avatar_image is a user-controlled URL; do NOT interpolate it into
|
||||||
|
// insertAdjacentHTML — filterXSS encodes " to " 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 = `
|
const msgHTML = `
|
||||||
<div class="msg left-msg">
|
<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-caption-bubble">
|
||||||
<div class="msg-info">
|
<div class="msg-info">
|
||||||
<div class="msg-info-name">${peer_name} : ${time_stamp}</div>
|
<div class="msg-info-name">${peer_name} : ${time_stamp}</div>
|
||||||
@@ -10589,6 +10624,11 @@ function handleSpeechTranscript(config) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
captionChat.insertAdjacentHTML('beforeend', msgHTML);
|
captionChat.insertAdjacentHTML('beforeend', msgHTML);
|
||||||
|
const captionAvatarEl = document.getElementById(captionAvatarTmpId);
|
||||||
|
if (captionAvatarEl) {
|
||||||
|
captionAvatarEl.setAttribute('src', avatar_image);
|
||||||
|
captionAvatarEl.removeAttribute('id');
|
||||||
|
}
|
||||||
captionChat.scrollTop += 500;
|
captionChat.scrollTop += 500;
|
||||||
transcripts.push({
|
transcripts.push({
|
||||||
time: time_stamp,
|
time: time_stamp,
|
||||||
@@ -10652,11 +10692,14 @@ function appendMessage(from, img, side, msg, privateMsg, msgId = null, to = '')
|
|||||||
// check if i receive a private message
|
// check if i receive a private message
|
||||||
let msgBubble = getPrivateMsg ? 'private-msg-bubble' : 'msg-bubble';
|
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 = `
|
let msgHTML = `
|
||||||
<div id="msg-${chatMessagesId}" class="msg ${getSide}-msg" data-sender="${getFrom}" data-chat-type="${
|
<div id="msg-${chatMessagesId}" class="msg ${getSide}-msg" data-sender="${getFrom}" data-chat-type="${
|
||||||
getPrivateMsg ? 'private' : 'public'
|
getPrivateMsg ? 'private' : 'public'
|
||||||
}" data-chat-peer="${conversationPeer}" data-msg-id="${normalizedMsgId}">
|
}" 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=${msgBubble}>
|
||||||
<div class="msg-info">
|
<div class="msg-info">
|
||||||
<div class="msg-info-name">${getFrom}</div>
|
<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);
|
msgerChat.insertAdjacentHTML('beforeend', msgHTML);
|
||||||
|
const msgAvatarEl = document.getElementById(msgAvatarTmpId);
|
||||||
|
if (msgAvatarEl) {
|
||||||
|
msgAvatarEl.setAttribute('src', getImg);
|
||||||
|
msgAvatarEl.removeAttribute('id');
|
||||||
|
}
|
||||||
|
|
||||||
const message = getId(`message-${chatMessagesId}`);
|
const message = getId(`message-${chatMessagesId}`);
|
||||||
if (message) {
|
if (message) {
|
||||||
@@ -11280,7 +11328,7 @@ async function msgerAddPeers(peers) {
|
|||||||
// if there isn't add it....
|
// if there isn't add it....
|
||||||
if (!exsistMsgerPrivateDiv) {
|
if (!exsistMsgerPrivateDiv) {
|
||||||
const chatAvatar =
|
const chatAvatar =
|
||||||
peer_avatar && isImageURL(peer_avatar)
|
peer_avatar && isValidAvatarURL(peer_avatar)
|
||||||
? peer_avatar
|
? peer_avatar
|
||||||
: isValidEmail(peer_name)
|
: isValidEmail(peer_name)
|
||||||
? genGravatar(peer_name)
|
? genGravatar(peer_name)
|
||||||
@@ -11634,6 +11682,8 @@ function isValidHttpURL(input) {
|
|||||||
*/
|
*/
|
||||||
function isImageURL(input) {
|
function isImageURL(input) {
|
||||||
if (!input || typeof input !== 'string') return false;
|
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 {
|
try {
|
||||||
const url = new URL(input);
|
const url = new URL(input);
|
||||||
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg'].some((ext) =>
|
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
|
* Check if Image File
|
||||||
* @return boolean
|
* @return boolean
|
||||||
@@ -12075,12 +12143,171 @@ async function updateMyPeerName() {
|
|||||||
userLog('toast', 'My name changed to ' + myPeerName);
|
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
|
* Append updated peer name to video player
|
||||||
* @param {object} config data
|
* @param {object} config data
|
||||||
*/
|
*/
|
||||||
function handlePeerName(config) {
|
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 videoName = getId(peer_id + '_name');
|
||||||
const screenName = getId(peer_id + '_screen_name');
|
const screenName = getId(peer_id + '_screen_name');
|
||||||
if (videoName) videoName.innerText = peer_name;
|
if (videoName) videoName.innerText = peer_name;
|
||||||
@@ -12092,7 +12319,7 @@ function handlePeerName(config) {
|
|||||||
|
|
||||||
if (msgerPeerAvatar) {
|
if (msgerPeerAvatar) {
|
||||||
msgerPeerAvatar.src =
|
msgerPeerAvatar.src =
|
||||||
peer_avatar && isImageURL(peer_avatar)
|
peer_avatar && isValidAvatarURL(peer_avatar)
|
||||||
? peer_avatar
|
? peer_avatar
|
||||||
: isValidEmail(peer_name)
|
: isValidEmail(peer_name)
|
||||||
? genGravatar(peer_name)
|
? genGravatar(peer_name)
|
||||||
|
|||||||
@@ -1048,6 +1048,14 @@ access to use this app.
|
|||||||
<div>
|
<div>
|
||||||
<img id="myProfileAvatar" />
|
<img id="myProfileAvatar" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<button id="myProfileAvatarUploadBtn">
|
||||||
|
<i class="fas fa-image"></i> Set avatar URL
|
||||||
|
</button>
|
||||||
|
<button id="myProfileAvatarResetBtn" class="hidden">
|
||||||
|
<i class="fas fa-rotate-left"></i> Reset avatar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<i class="fas fa-user"></i>
|
<i class="fas fa-user"></i>
|
||||||
|
|||||||