bug fixes & ui improvements
This commit is contained in:
parent
08ff4755a1
commit
55760ba368
4 changed files with 308 additions and 72 deletions
|
|
@ -617,29 +617,12 @@ async function executeKeyRotation() {
|
||||||
clearAction("key_rotation_exec");
|
clearAction("key_rotation_exec");
|
||||||
showBlahNotification("success:key.rotated");
|
showBlahNotification("success:key.rotated");
|
||||||
|
|
||||||
// Retry sending the message!
|
|
||||||
if(pendingRotationMessagePayload) {
|
|
||||||
let p = pendingRotationMessagePayload;
|
|
||||||
p.string5 = newIndex;
|
|
||||||
let dmKeyBytes = await sha256Bytes(base64ToUint8(roomKey));
|
|
||||||
p.string2 = await encryptAesGcmToBase64(p.rawPlainContent, dmKeyBytes); // we need to save rawPlainContent for retry
|
|
||||||
|
|
||||||
showAction("action.message.sending", "msgsending");
|
|
||||||
let resSend = await fetchEncrypted("group/message/send", JSON.stringify(p));
|
|
||||||
clearAction("msgsending");
|
|
||||||
|
|
||||||
if(resSend && resSend.startsWith("error:")) {
|
|
||||||
showBlahNotification(resSend);
|
|
||||||
} else if(resSend && resSend.startsWith("success:")) {
|
|
||||||
loadDmMessages(currentDmId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingRotationMessagePayload = null;
|
pendingRotationMessagePayload = null;
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
clearAction("key_rotation_exec");
|
clearAction("key_rotation_exec");
|
||||||
console.error(e);
|
showBlahNotification(e.message || e);
|
||||||
showBlahNotification(e.message && e.message.startsWith("error:") ? e.message : "error:something.wrong");
|
pendingRotationMessagePayload = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2145,7 +2128,7 @@ async function renderInvites(res, type) {
|
||||||
let actions = "";
|
let actions = "";
|
||||||
let desc = "";
|
let desc = "";
|
||||||
|
|
||||||
let displayName = username.split(':')[0] || onlyId;
|
let displayName = username || onlyId;
|
||||||
if (type === "received") {
|
if (type === "received") {
|
||||||
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
|
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
|
||||||
actions = `
|
actions = `
|
||||||
|
|
@ -2199,7 +2182,7 @@ async function renderInvites(res, type) {
|
||||||
let actions = "";
|
let actions = "";
|
||||||
let desc = "";
|
let desc = "";
|
||||||
|
|
||||||
let displayName = username.split(':')[0] || onlyId;
|
let displayName = username || onlyId;
|
||||||
let val1 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${displayName}</b></span>`;
|
let val1 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${displayName}</b></span>`;
|
||||||
let val2 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${onlyId}</b></span>`;
|
let val2 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${onlyId}</b></span>`;
|
||||||
|
|
||||||
|
|
@ -2403,6 +2386,13 @@ function gotoJoinSpace() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gotoHome(splash = true) {
|
async function gotoHome(splash = true) {
|
||||||
|
if (dmMessagePollInterval) {
|
||||||
|
clearInterval(dmMessagePollInterval);
|
||||||
|
dmMessagePollInterval = null;
|
||||||
|
}
|
||||||
|
currentDmId = null;
|
||||||
|
window.currentRoomType = null;
|
||||||
|
|
||||||
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
|
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
|
||||||
setActiveSidebarIndicator(sidebarHomeIndicator);
|
setActiveSidebarIndicator(sidebarHomeIndicator);
|
||||||
if (splash) {
|
if (splash) {
|
||||||
|
|
@ -2595,6 +2585,8 @@ async function renderDms(res) {
|
||||||
|
|
||||||
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
|
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
|
||||||
|
|
||||||
|
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${targetId}')">
|
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${targetId}')">
|
||||||
<img src="${pfp}" class="room-pfp">
|
<img src="${pfp}" class="room-pfp">
|
||||||
|
|
@ -2602,6 +2594,7 @@ async function renderDms(res) {
|
||||||
<span class="room-name" style="${nameStyle}">${displayName}</span>
|
<span class="room-name" style="${nameStyle}">${displayName}</span>
|
||||||
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
|
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
|
||||||
</div>
|
</div>
|
||||||
|
${unreadBadge}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
count++;
|
count++;
|
||||||
|
|
@ -2655,6 +2648,8 @@ async function renderGroups(res) {
|
||||||
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
||||||
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
||||||
|
|
||||||
|
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${groupId}')">
|
<button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${groupId}')">
|
||||||
<img src="${pfp}" class="room-pfp">
|
<img src="${pfp}" class="room-pfp">
|
||||||
|
|
@ -2662,6 +2657,7 @@ async function renderGroups(res) {
|
||||||
<span class="room-name" style="${nameStyle}">${groupName}</span>
|
<span class="room-name" style="${nameStyle}">${groupName}</span>
|
||||||
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${groupId.replace(';', ':')}</span>
|
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${groupId.replace(';', ':')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${unreadBadge}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
count++;
|
count++;
|
||||||
|
|
@ -2766,9 +2762,63 @@ async function inviteToGroupPrompt(groupId) {
|
||||||
try {
|
try {
|
||||||
showAction("action.invite.sending", "invite.action");
|
showAction("action.invite.sending", "invite.action");
|
||||||
|
|
||||||
|
let keysPayload = "";
|
||||||
|
try {
|
||||||
|
let infoRes = await fetchEncrypted("group/info", groupId);
|
||||||
|
if (infoRes && !infoRes.startsWith("error:")) {
|
||||||
|
let info = JSON.parse(infoRes);
|
||||||
|
if (info.string6 === "true") { // historyVisible
|
||||||
|
let lastKeyIndex = parseInt(info.string4);
|
||||||
|
if (!isNaN(lastKeyIndex)) {
|
||||||
|
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`);
|
||||||
|
if (publicKeysRaw && publicKeysRaw.length > 10) {
|
||||||
|
let publicKeys = JSON.parse(publicKeysRaw);
|
||||||
|
let hybrid = await deriveHybridKeyToUser(publicKeys);
|
||||||
|
let myPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${window.id}`);
|
||||||
|
let keysDict = {};
|
||||||
|
let allKeysSuccess = true;
|
||||||
|
|
||||||
|
for (let i = 0; i <= lastKeyIndex; i++) {
|
||||||
|
let roomKey = await getGroupKey(groupId, i.toString());
|
||||||
|
if (!roomKey) {
|
||||||
|
allKeysSuccess = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let encForMember = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
|
||||||
|
let setupPayloadJson = JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
alg: "hybrid-x25519-mlkem768",
|
||||||
|
ctMlKem: hybrid.ciphertextMlKemBase64,
|
||||||
|
enc: encForMember
|
||||||
|
});
|
||||||
|
keysDict[i.toString()] = `SETUP:${myPublicKeysRaw};${setupPayloadJson}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allKeysSuccess) {
|
||||||
|
clearAction("invite.action");
|
||||||
|
showBlahNotification("error:failed.to.prepare.keys");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keysPayload = JSON.stringify(keysDict);
|
||||||
|
} else {
|
||||||
|
clearAction("invite.action");
|
||||||
|
showBlahNotification("error:failed.to.prepare.keys");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error generating keys payload for invite", e);
|
||||||
|
clearAction("invite.action");
|
||||||
|
showBlahNotification("error:failed.to.prepare.keys");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let payload = JSON.stringify({
|
let payload = JSON.stringify({
|
||||||
string1: groupId,
|
string1: groupId,
|
||||||
string2: targetId
|
string2: targetId,
|
||||||
|
string3: keysPayload
|
||||||
});
|
});
|
||||||
|
|
||||||
let res = await fetchEncrypted("group/invite", payload);
|
let res = await fetchEncrypted("group/invite", payload);
|
||||||
|
|
@ -2861,10 +2911,10 @@ async function openDm(dmId, username, targetId, type = 'dms') {
|
||||||
let iconHtml = "";
|
let iconHtml = "";
|
||||||
if (type === 'dms') {
|
if (type === 'dms') {
|
||||||
let pfp = await getAvatarUrl(targetId, username);
|
let pfp = await getAvatarUrl(targetId, username);
|
||||||
iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
||||||
} else {
|
} else {
|
||||||
let pfp = await getGroupAvatarUrl(dmId, username);
|
let pfp = await getGroupAvatarUrl(dmId, username);
|
||||||
iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentRoomsBarTitle !== "title.home") {
|
if (currentRoomsBarTitle !== "title.home") {
|
||||||
|
|
@ -3022,8 +3072,27 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
|
||||||
if (atts.length > 0) {
|
if (atts.length > 0) {
|
||||||
attachmentsHtml += `<div class="chat-message-attachments" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">`;
|
attachmentsHtml += `<div class="chat-message-attachments" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">`;
|
||||||
for (let att of atts) {
|
for (let att of atts) {
|
||||||
|
if (att.enc && att.enc.startsWith("ENC:")) {
|
||||||
|
try {
|
||||||
|
if (keyToUse) {
|
||||||
|
let keyBytes = await sha256Bytes(base64ToUint8(keyToUse));
|
||||||
|
let decStr = await decryptAesGcmFromBase64(att.enc.substring(4), keyBytes);
|
||||||
|
let metadata = JSON.parse(decStr);
|
||||||
|
att.name = metadata.name || "unknown";
|
||||||
|
att.type = metadata.type || "application/octet-stream";
|
||||||
|
att.size = metadata.size || 0;
|
||||||
|
} else {
|
||||||
|
att.name = processBlah("error:messages.decrypt.failed");
|
||||||
|
att.type = "application/octet-stream";
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
att.name = processBlah("error:messages.decrypt.failed");
|
||||||
|
att.type = "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let attContainerId = `att-container-${msgId}-${att.id}`;
|
let attContainerId = `att-container-${msgId}-${att.id}`;
|
||||||
let isMedia = att.type.startsWith("image/") || att.type.startsWith("video/");
|
let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/"));
|
||||||
let sizeStr = att.size ? formatBytes(att.size) : "";
|
let sizeStr = att.size ? formatBytes(att.size) : "";
|
||||||
let displayName = truncateFilename(att.name, 25);
|
let displayName = truncateFilename(att.name, 25);
|
||||||
let iconSvg = isMedia
|
let iconSvg = isMedia
|
||||||
|
|
@ -3528,7 +3597,10 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading</div>`;
|
if (window.currentRoomType === 'groups' && window.groupKeysCache[currentDmId]) {
|
||||||
|
delete window.groupKeysCache[currentDmId][keyIndex];
|
||||||
|
}
|
||||||
|
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading - click to retry</div>`;
|
||||||
container.dataset.loading = "false";
|
container.dataset.loading = "false";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3655,12 +3727,17 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decText && decText.startsWith("success:")) {
|
if (decText && decText.startsWith("success:")) {
|
||||||
attachmentsPayload.push({
|
let metadataObj = {
|
||||||
id: i,
|
|
||||||
type: file.type,
|
type: file.type,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size
|
||||||
staging_id: decText.split(":")[1]
|
};
|
||||||
|
let encMetadata = "ENC:" + await encryptAesGcmToBase64(JSON.stringify(metadataObj), dmKeyBytes);
|
||||||
|
|
||||||
|
attachmentsPayload.push({
|
||||||
|
id: i,
|
||||||
|
staging_id: decText.split(":")[1],
|
||||||
|
enc: encMetadata
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
|
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
|
||||||
|
|
@ -3678,9 +3755,11 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
|
||||||
|
|
||||||
if (window.currentRoomType === 'groups') {
|
if (window.currentRoomType === 'groups') {
|
||||||
msgPayload.string5 = window.currentGroupKeyIndex || "0";
|
msgPayload.string5 = window.currentGroupKeyIndex || "0";
|
||||||
msgPayload.rawPlainContent = content; // for retry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let savedContentForRestore = content;
|
||||||
|
let savedAttachmentsForRestore = [...currentDraftAttachments];
|
||||||
|
|
||||||
input.value = "";
|
input.value = "";
|
||||||
input.style.height = '2.5rem';
|
input.style.height = '2.5rem';
|
||||||
sendbutton.style.height = '2.5rem';
|
sendbutton.style.height = '2.5rem';
|
||||||
|
|
@ -3700,7 +3779,13 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
|
||||||
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
|
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
|
||||||
let pElem = document.getElementById(pendingMsgId);
|
let pElem = document.getElementById(pendingMsgId);
|
||||||
if (pElem) pElem.remove();
|
if (pElem) pElem.remove();
|
||||||
showRotationPopup(currentDmId, msgPayload);
|
|
||||||
|
// Restore input and attachments for the user to retry manually
|
||||||
|
input.value = savedContentForRestore;
|
||||||
|
currentDraftAttachments = savedAttachmentsForRestore;
|
||||||
|
renderAttachmentsPreview();
|
||||||
|
|
||||||
|
showRotationPopup(currentDmId, null);
|
||||||
} else if (res && res.startsWith("error:")) {
|
} else if (res && res.startsWith("error:")) {
|
||||||
showBlahNotification(res);
|
showBlahNotification(res);
|
||||||
let pElem = document.getElementById(pendingMsgId);
|
let pElem = document.getElementById(pendingMsgId);
|
||||||
|
|
@ -4365,8 +4450,8 @@ function initCropper() {
|
||||||
let zoomSlider = document.getElementById('cropper-zoom');
|
let zoomSlider = document.getElementById('cropper-zoom');
|
||||||
|
|
||||||
// Set aspect ratio
|
// Set aspect ratio
|
||||||
let targetWidth = cropperType === 'avatar' ? 512 : 494;
|
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 494;
|
||||||
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
|
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? targetWidth : 200;
|
||||||
let aspectRatio = targetWidth / targetHeight;
|
let aspectRatio = targetWidth / targetHeight;
|
||||||
|
|
||||||
container.style.height = 'auto';
|
container.style.height = 'auto';
|
||||||
|
|
@ -4434,8 +4519,8 @@ async function confirmCropper() {
|
||||||
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
|
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
|
||||||
let container = document.getElementById('cropper-viewport-container');
|
let container = document.getElementById('cropper-viewport-container');
|
||||||
|
|
||||||
let targetWidth = cropperType === 'avatar' ? 512 : 988;
|
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 988;
|
||||||
let targetHeight = cropperType === 'avatar' ? 512 : 400;
|
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 400;
|
||||||
let scaleRatio = targetWidth / container.clientWidth;
|
let scaleRatio = targetWidth / container.clientWidth;
|
||||||
let drawX = cropperPosX * scaleRatio;
|
let drawX = cropperPosX * scaleRatio;
|
||||||
let drawY = cropperPosY * scaleRatio;
|
let drawY = cropperPosY * scaleRatio;
|
||||||
|
|
@ -4705,6 +4790,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
|
||||||
if (nameSpan) {
|
if (nameSpan) {
|
||||||
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
||||||
}
|
}
|
||||||
|
let badge = btn.querySelector('.unread-badge');
|
||||||
|
if (badge) {
|
||||||
|
if (isRead) badge.classList.remove("show");
|
||||||
|
else badge.classList.add("show");
|
||||||
|
}
|
||||||
if (timestamp) {
|
if (timestamp) {
|
||||||
container.prepend(btn);
|
container.prepend(btn);
|
||||||
}
|
}
|
||||||
|
|
@ -4737,6 +4827,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
|
||||||
if (nameSpan) {
|
if (nameSpan) {
|
||||||
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
||||||
}
|
}
|
||||||
|
let badge = btn.querySelector('.unread-badge');
|
||||||
|
if (badge) {
|
||||||
|
if (isRead) badge.classList.remove("show");
|
||||||
|
else badge.classList.add("show");
|
||||||
|
}
|
||||||
if (timestamp) {
|
if (timestamp) {
|
||||||
container.prepend(btn);
|
container.prepend(btn);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1145,3 +1145,26 @@ input:checked + .toggle-slider:before {
|
||||||
color: var(--darker-text-color);
|
color: var(--darker-text-color);
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unread-badge {
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
min-width: 0.65rem;
|
||||||
|
min-height: 0.65rem;
|
||||||
|
background-color: var(--big-red);
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-badge.show {
|
||||||
|
transform: scale(1);
|
||||||
|
animation: badge-appear 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badge-appear {
|
||||||
|
0% { transform: scale(0); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
|
||||||
167
webroot/main.js
167
webroot/main.js
|
|
@ -617,29 +617,12 @@ async function executeKeyRotation() {
|
||||||
clearAction("key_rotation_exec");
|
clearAction("key_rotation_exec");
|
||||||
showBlahNotification("success:key.rotated");
|
showBlahNotification("success:key.rotated");
|
||||||
|
|
||||||
// Retry sending the message!
|
|
||||||
if(pendingRotationMessagePayload) {
|
|
||||||
let p = pendingRotationMessagePayload;
|
|
||||||
p.string5 = newIndex;
|
|
||||||
let dmKeyBytes = await sha256Bytes(base64ToUint8(roomKey));
|
|
||||||
p.string2 = await encryptAesGcmToBase64(p.rawPlainContent, dmKeyBytes); // we need to save rawPlainContent for retry
|
|
||||||
|
|
||||||
showAction("action.message.sending", "msgsending");
|
|
||||||
let resSend = await fetchEncrypted("group/message/send", JSON.stringify(p));
|
|
||||||
clearAction("msgsending");
|
|
||||||
|
|
||||||
if(resSend && resSend.startsWith("error:")) {
|
|
||||||
showBlahNotification(resSend);
|
|
||||||
} else if(resSend && resSend.startsWith("success:")) {
|
|
||||||
loadDmMessages(currentDmId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingRotationMessagePayload = null;
|
pendingRotationMessagePayload = null;
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
clearAction("key_rotation_exec");
|
clearAction("key_rotation_exec");
|
||||||
console.error(e);
|
showBlahNotification(e.message || e);
|
||||||
showBlahNotification(e.message && e.message.startsWith("error:") ? e.message : "error:something.wrong");
|
pendingRotationMessagePayload = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2145,7 +2128,7 @@ async function renderInvites(res, type) {
|
||||||
let actions = "";
|
let actions = "";
|
||||||
let desc = "";
|
let desc = "";
|
||||||
|
|
||||||
let displayName = username.split(':')[0] || onlyId;
|
let displayName = username || onlyId;
|
||||||
if (type === "received") {
|
if (type === "received") {
|
||||||
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
|
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
|
||||||
actions = `
|
actions = `
|
||||||
|
|
@ -2199,7 +2182,7 @@ async function renderInvites(res, type) {
|
||||||
let actions = "";
|
let actions = "";
|
||||||
let desc = "";
|
let desc = "";
|
||||||
|
|
||||||
let displayName = username.split(':')[0] || onlyId;
|
let displayName = username || onlyId;
|
||||||
let val1 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${displayName}</b></span>`;
|
let val1 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${displayName}</b></span>`;
|
||||||
let val2 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${onlyId}</b></span>`;
|
let val2 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${onlyId}</b></span>`;
|
||||||
|
|
||||||
|
|
@ -2403,6 +2386,13 @@ function gotoJoinSpace() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gotoHome(splash = true) {
|
async function gotoHome(splash = true) {
|
||||||
|
if (dmMessagePollInterval) {
|
||||||
|
clearInterval(dmMessagePollInterval);
|
||||||
|
dmMessagePollInterval = null;
|
||||||
|
}
|
||||||
|
currentDmId = null;
|
||||||
|
window.currentRoomType = null;
|
||||||
|
|
||||||
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
|
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
|
||||||
setActiveSidebarIndicator(sidebarHomeIndicator);
|
setActiveSidebarIndicator(sidebarHomeIndicator);
|
||||||
if (splash) {
|
if (splash) {
|
||||||
|
|
@ -2595,6 +2585,8 @@ async function renderDms(res) {
|
||||||
|
|
||||||
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
|
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
|
||||||
|
|
||||||
|
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${targetId}')">
|
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${targetId}')">
|
||||||
<img src="${pfp}" class="room-pfp">
|
<img src="${pfp}" class="room-pfp">
|
||||||
|
|
@ -2602,6 +2594,7 @@ async function renderDms(res) {
|
||||||
<span class="room-name" style="${nameStyle}">${displayName}</span>
|
<span class="room-name" style="${nameStyle}">${displayName}</span>
|
||||||
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
|
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
|
||||||
</div>
|
</div>
|
||||||
|
${unreadBadge}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
count++;
|
count++;
|
||||||
|
|
@ -2655,6 +2648,8 @@ async function renderGroups(res) {
|
||||||
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
||||||
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
||||||
|
|
||||||
|
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${groupId}')">
|
<button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${groupId}')">
|
||||||
<img src="${pfp}" class="room-pfp">
|
<img src="${pfp}" class="room-pfp">
|
||||||
|
|
@ -2662,6 +2657,7 @@ async function renderGroups(res) {
|
||||||
<span class="room-name" style="${nameStyle}">${groupName}</span>
|
<span class="room-name" style="${nameStyle}">${groupName}</span>
|
||||||
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${groupId.replace(';', ':')}</span>
|
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${groupId.replace(';', ':')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${unreadBadge}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
count++;
|
count++;
|
||||||
|
|
@ -2766,9 +2762,63 @@ async function inviteToGroupPrompt(groupId) {
|
||||||
try {
|
try {
|
||||||
showAction("action.invite.sending", "invite.action");
|
showAction("action.invite.sending", "invite.action");
|
||||||
|
|
||||||
|
let keysPayload = "";
|
||||||
|
try {
|
||||||
|
let infoRes = await fetchEncrypted("group/info", groupId);
|
||||||
|
if (infoRes && !infoRes.startsWith("error:")) {
|
||||||
|
let info = JSON.parse(infoRes);
|
||||||
|
if (info.string6 === "true") { // historyVisible
|
||||||
|
let lastKeyIndex = parseInt(info.string4);
|
||||||
|
if (!isNaN(lastKeyIndex)) {
|
||||||
|
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`);
|
||||||
|
if (publicKeysRaw && publicKeysRaw.length > 10) {
|
||||||
|
let publicKeys = JSON.parse(publicKeysRaw);
|
||||||
|
let hybrid = await deriveHybridKeyToUser(publicKeys);
|
||||||
|
let myPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${window.id}`);
|
||||||
|
let keysDict = {};
|
||||||
|
let allKeysSuccess = true;
|
||||||
|
|
||||||
|
for (let i = 0; i <= lastKeyIndex; i++) {
|
||||||
|
let roomKey = await getGroupKey(groupId, i.toString());
|
||||||
|
if (!roomKey) {
|
||||||
|
allKeysSuccess = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let encForMember = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
|
||||||
|
let setupPayloadJson = JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
alg: "hybrid-x25519-mlkem768",
|
||||||
|
ctMlKem: hybrid.ciphertextMlKemBase64,
|
||||||
|
enc: encForMember
|
||||||
|
});
|
||||||
|
keysDict[i.toString()] = `SETUP:${myPublicKeysRaw};${setupPayloadJson}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allKeysSuccess) {
|
||||||
|
clearAction("invite.action");
|
||||||
|
showBlahNotification("error:failed.to.prepare.keys");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keysPayload = JSON.stringify(keysDict);
|
||||||
|
} else {
|
||||||
|
clearAction("invite.action");
|
||||||
|
showBlahNotification("error:failed.to.prepare.keys");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error generating keys payload for invite", e);
|
||||||
|
clearAction("invite.action");
|
||||||
|
showBlahNotification("error:failed.to.prepare.keys");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let payload = JSON.stringify({
|
let payload = JSON.stringify({
|
||||||
string1: groupId,
|
string1: groupId,
|
||||||
string2: targetId
|
string2: targetId,
|
||||||
|
string3: keysPayload
|
||||||
});
|
});
|
||||||
|
|
||||||
let res = await fetchEncrypted("group/invite", payload);
|
let res = await fetchEncrypted("group/invite", payload);
|
||||||
|
|
@ -2861,10 +2911,10 @@ async function openDm(dmId, username, targetId, type = 'dms') {
|
||||||
let iconHtml = "";
|
let iconHtml = "";
|
||||||
if (type === 'dms') {
|
if (type === 'dms') {
|
||||||
let pfp = await getAvatarUrl(targetId, username);
|
let pfp = await getAvatarUrl(targetId, username);
|
||||||
iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
||||||
} else {
|
} else {
|
||||||
let pfp = await getGroupAvatarUrl(dmId, username);
|
let pfp = await getGroupAvatarUrl(dmId, username);
|
||||||
iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentRoomsBarTitle !== "title.home") {
|
if (currentRoomsBarTitle !== "title.home") {
|
||||||
|
|
@ -3022,8 +3072,27 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
|
||||||
if (atts.length > 0) {
|
if (atts.length > 0) {
|
||||||
attachmentsHtml += `<div class="chat-message-attachments" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">`;
|
attachmentsHtml += `<div class="chat-message-attachments" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">`;
|
||||||
for (let att of atts) {
|
for (let att of atts) {
|
||||||
|
if (att.enc && att.enc.startsWith("ENC:")) {
|
||||||
|
try {
|
||||||
|
if (keyToUse) {
|
||||||
|
let keyBytes = await sha256Bytes(base64ToUint8(keyToUse));
|
||||||
|
let decStr = await decryptAesGcmFromBase64(att.enc.substring(4), keyBytes);
|
||||||
|
let metadata = JSON.parse(decStr);
|
||||||
|
att.name = metadata.name || "unknown";
|
||||||
|
att.type = metadata.type || "application/octet-stream";
|
||||||
|
att.size = metadata.size || 0;
|
||||||
|
} else {
|
||||||
|
att.name = processBlah("error:messages.decrypt.failed");
|
||||||
|
att.type = "application/octet-stream";
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
att.name = processBlah("error:messages.decrypt.failed");
|
||||||
|
att.type = "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let attContainerId = `att-container-${msgId}-${att.id}`;
|
let attContainerId = `att-container-${msgId}-${att.id}`;
|
||||||
let isMedia = att.type.startsWith("image/") || att.type.startsWith("video/");
|
let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/"));
|
||||||
let sizeStr = att.size ? formatBytes(att.size) : "";
|
let sizeStr = att.size ? formatBytes(att.size) : "";
|
||||||
let displayName = truncateFilename(att.name, 25);
|
let displayName = truncateFilename(att.name, 25);
|
||||||
let iconSvg = isMedia
|
let iconSvg = isMedia
|
||||||
|
|
@ -3528,7 +3597,10 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading</div>`;
|
if (window.currentRoomType === 'groups' && window.groupKeysCache[currentDmId]) {
|
||||||
|
delete window.groupKeysCache[currentDmId][keyIndex];
|
||||||
|
}
|
||||||
|
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading - click to retry</div>`;
|
||||||
container.dataset.loading = "false";
|
container.dataset.loading = "false";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3655,12 +3727,17 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decText && decText.startsWith("success:")) {
|
if (decText && decText.startsWith("success:")) {
|
||||||
attachmentsPayload.push({
|
let metadataObj = {
|
||||||
id: i,
|
|
||||||
type: file.type,
|
type: file.type,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size
|
||||||
staging_id: decText.split(":")[1]
|
};
|
||||||
|
let encMetadata = "ENC:" + await encryptAesGcmToBase64(JSON.stringify(metadataObj), dmKeyBytes);
|
||||||
|
|
||||||
|
attachmentsPayload.push({
|
||||||
|
id: i,
|
||||||
|
staging_id: decText.split(":")[1],
|
||||||
|
enc: encMetadata
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
|
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
|
||||||
|
|
@ -3678,9 +3755,11 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
|
||||||
|
|
||||||
if (window.currentRoomType === 'groups') {
|
if (window.currentRoomType === 'groups') {
|
||||||
msgPayload.string5 = window.currentGroupKeyIndex || "0";
|
msgPayload.string5 = window.currentGroupKeyIndex || "0";
|
||||||
msgPayload.rawPlainContent = content; // for retry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let savedContentForRestore = content;
|
||||||
|
let savedAttachmentsForRestore = [...currentDraftAttachments];
|
||||||
|
|
||||||
input.value = "";
|
input.value = "";
|
||||||
input.style.height = '2.5rem';
|
input.style.height = '2.5rem';
|
||||||
sendbutton.style.height = '2.5rem';
|
sendbutton.style.height = '2.5rem';
|
||||||
|
|
@ -3700,7 +3779,13 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
|
||||||
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
|
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
|
||||||
let pElem = document.getElementById(pendingMsgId);
|
let pElem = document.getElementById(pendingMsgId);
|
||||||
if (pElem) pElem.remove();
|
if (pElem) pElem.remove();
|
||||||
showRotationPopup(currentDmId, msgPayload);
|
|
||||||
|
// Restore input and attachments for the user to retry manually
|
||||||
|
input.value = savedContentForRestore;
|
||||||
|
currentDraftAttachments = savedAttachmentsForRestore;
|
||||||
|
renderAttachmentsPreview();
|
||||||
|
|
||||||
|
showRotationPopup(currentDmId, null);
|
||||||
} else if (res && res.startsWith("error:")) {
|
} else if (res && res.startsWith("error:")) {
|
||||||
showBlahNotification(res);
|
showBlahNotification(res);
|
||||||
let pElem = document.getElementById(pendingMsgId);
|
let pElem = document.getElementById(pendingMsgId);
|
||||||
|
|
@ -4365,8 +4450,8 @@ function initCropper() {
|
||||||
let zoomSlider = document.getElementById('cropper-zoom');
|
let zoomSlider = document.getElementById('cropper-zoom');
|
||||||
|
|
||||||
// Set aspect ratio
|
// Set aspect ratio
|
||||||
let targetWidth = cropperType === 'avatar' ? 512 : 494;
|
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 494;
|
||||||
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
|
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? targetWidth : 200;
|
||||||
let aspectRatio = targetWidth / targetHeight;
|
let aspectRatio = targetWidth / targetHeight;
|
||||||
|
|
||||||
container.style.height = 'auto';
|
container.style.height = 'auto';
|
||||||
|
|
@ -4434,8 +4519,8 @@ async function confirmCropper() {
|
||||||
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
|
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
|
||||||
let container = document.getElementById('cropper-viewport-container');
|
let container = document.getElementById('cropper-viewport-container');
|
||||||
|
|
||||||
let targetWidth = cropperType === 'avatar' ? 512 : 988;
|
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 988;
|
||||||
let targetHeight = cropperType === 'avatar' ? 512 : 400;
|
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 400;
|
||||||
let scaleRatio = targetWidth / container.clientWidth;
|
let scaleRatio = targetWidth / container.clientWidth;
|
||||||
let drawX = cropperPosX * scaleRatio;
|
let drawX = cropperPosX * scaleRatio;
|
||||||
let drawY = cropperPosY * scaleRatio;
|
let drawY = cropperPosY * scaleRatio;
|
||||||
|
|
@ -4705,6 +4790,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
|
||||||
if (nameSpan) {
|
if (nameSpan) {
|
||||||
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
||||||
}
|
}
|
||||||
|
let badge = btn.querySelector('.unread-badge');
|
||||||
|
if (badge) {
|
||||||
|
if (isRead) badge.classList.remove("show");
|
||||||
|
else badge.classList.add("show");
|
||||||
|
}
|
||||||
if (timestamp) {
|
if (timestamp) {
|
||||||
container.prepend(btn);
|
container.prepend(btn);
|
||||||
}
|
}
|
||||||
|
|
@ -4737,6 +4827,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
|
||||||
if (nameSpan) {
|
if (nameSpan) {
|
||||||
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
nameSpan.style.opacity = isRead ? "0.8" : "1";
|
||||||
}
|
}
|
||||||
|
let badge = btn.querySelector('.unread-badge');
|
||||||
|
if (badge) {
|
||||||
|
if (isRead) badge.classList.remove("show");
|
||||||
|
else badge.classList.add("show");
|
||||||
|
}
|
||||||
if (timestamp) {
|
if (timestamp) {
|
||||||
container.prepend(btn);
|
container.prepend(btn);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1145,3 +1145,26 @@ input:checked + .toggle-slider:before {
|
||||||
color: var(--darker-text-color);
|
color: var(--darker-text-color);
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unread-badge {
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
min-width: 0.65rem;
|
||||||
|
min-height: 0.65rem;
|
||||||
|
background-color: var(--big-red);
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-badge.show {
|
||||||
|
transform: scale(1);
|
||||||
|
animation: badge-appear 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badge-appear {
|
||||||
|
0% { transform: scale(0); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue