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/))
|
||||
- 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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
--------------------------------------------------------------*/
|
||||
|
||||
|
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 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 " 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)
|
||||
|
||||
@@ -1048,6 +1048,14 @@ access to use this app.
|
||||
<div>
|
||||
<img id="myProfileAvatar" />
|
||||
</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 />
|
||||
<div class="title">
|
||||
<i class="fas fa-user"></i>
|
||||
|
||||