diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js
index 7070e62d..3cd1e385 100644
--- a/android/app/src/main/assets/public/main.js
+++ b/android/app/src/main/assets/public/main.js
@@ -617,29 +617,12 @@ async function executeKeyRotation() {
clearAction("key_rotation_exec");
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;
} catch(e) {
clearAction("key_rotation_exec");
- console.error(e);
- showBlahNotification(e.message && e.message.startsWith("error:") ? e.message : "error:something.wrong");
+ showBlahNotification(e.message || e);
+ pendingRotationMessagePayload = null;
}
}
@@ -2145,7 +2128,7 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
- let displayName = username.split(':')[0] || onlyId;
+ let displayName = username || onlyId;
if (type === "received") {
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = `
@@ -2199,7 +2182,7 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
- let displayName = username.split(':')[0] || onlyId;
+ let displayName = username || onlyId;
let val1 = `${displayName}`;
let val2 = `${onlyId}`;
@@ -2403,6 +2386,13 @@ function gotoJoinSpace() {
}
async function gotoHome(splash = true) {
+ if (dmMessagePollInterval) {
+ clearInterval(dmMessagePollInterval);
+ dmMessagePollInterval = null;
+ }
+ currentDmId = null;
+ window.currentRoomType = null;
+
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarHomeIndicator);
if (splash) {
@@ -2595,6 +2585,8 @@ async function renderDms(res) {
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
+ let unreadBadge = `
`;
+
html += `
`;
count++;
@@ -2655,6 +2648,8 @@ async function renderGroups(res) {
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName);
+ let unreadBadge = ``;
+
html += `
`;
count++;
@@ -2766,9 +2762,63 @@ async function inviteToGroupPrompt(groupId) {
try {
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({
string1: groupId,
- string2: targetId
+ string2: targetId,
+ string3: keysPayload
});
let res = await fetchEncrypted("group/invite", payload);
@@ -2861,10 +2911,10 @@ async function openDm(dmId, username, targetId, type = 'dms') {
let iconHtml = "";
if (type === 'dms') {
let pfp = await getAvatarUrl(targetId, username);
- iconHtml = `
`;
+ iconHtml = `
`;
} else {
let pfp = await getGroupAvatarUrl(dmId, username);
- iconHtml = `
`;
+ iconHtml = `
`;
}
if (currentRoomsBarTitle !== "title.home") {
@@ -3022,8 +3072,27 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
if (atts.length > 0) {
attachmentsHtml += ``;
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 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 displayName = truncateFilename(att.name, 25);
let iconSvg = isMedia
@@ -3528,7 +3597,10 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name,
}
} catch (e) {
console.error(e);
- container.innerHTML = `
Error loading
`;
+ if (window.currentRoomType === 'groups' && window.groupKeysCache[currentDmId]) {
+ delete window.groupKeysCache[currentDmId][keyIndex];
+ }
+ container.innerHTML = `
Error loading - click to retry
`;
container.dataset.loading = "false";
}
}
@@ -3655,12 +3727,17 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
}
if (decText && decText.startsWith("success:")) {
- attachmentsPayload.push({
- id: i,
+ let metadataObj = {
type: file.type,
name: file.name,
- size: file.size,
- staging_id: decText.split(":")[1]
+ size: file.size
+ };
+ let encMetadata = "ENC:" + await encryptAesGcmToBase64(JSON.stringify(metadataObj), dmKeyBytes);
+
+ attachmentsPayload.push({
+ id: i,
+ staging_id: decText.split(":")[1],
+ enc: encMetadata
});
} else {
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') {
msgPayload.string5 = window.currentGroupKeyIndex || "0";
- msgPayload.rawPlainContent = content; // for retry
}
+ let savedContentForRestore = content;
+ let savedAttachmentsForRestore = [...currentDraftAttachments];
+
input.value = "";
input.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') {
let pElem = document.getElementById(pendingMsgId);
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:")) {
showBlahNotification(res);
let pElem = document.getElementById(pendingMsgId);
@@ -4365,8 +4450,8 @@ function initCropper() {
let zoomSlider = document.getElementById('cropper-zoom');
// Set aspect ratio
- let targetWidth = cropperType === 'avatar' ? 512 : 494;
- let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
+ let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 494;
+ let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? targetWidth : 200;
let aspectRatio = targetWidth / targetHeight;
container.style.height = 'auto';
@@ -4434,8 +4519,8 @@ async function confirmCropper() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let container = document.getElementById('cropper-viewport-container');
- let targetWidth = cropperType === 'avatar' ? 512 : 988;
- let targetHeight = cropperType === 'avatar' ? 512 : 400;
+ let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 988;
+ let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 400;
let scaleRatio = targetWidth / container.clientWidth;
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
@@ -4705,6 +4790,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
if (nameSpan) {
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) {
container.prepend(btn);
}
@@ -4737,6 +4827,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
if (nameSpan) {
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) {
container.prepend(btn);
}
diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css
index 23c4e5ac..94ec00ed 100644
--- a/android/app/src/main/assets/public/style.css
+++ b/android/app/src/main/assets/public/style.css
@@ -1145,3 +1145,26 @@ input:checked + .toggle-slider:before {
color: var(--darker-text-color);
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); }
+}
diff --git a/webroot/main.js b/webroot/main.js
index 7070e62d..3cd1e385 100644
--- a/webroot/main.js
+++ b/webroot/main.js
@@ -617,29 +617,12 @@ async function executeKeyRotation() {
clearAction("key_rotation_exec");
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;
} catch(e) {
clearAction("key_rotation_exec");
- console.error(e);
- showBlahNotification(e.message && e.message.startsWith("error:") ? e.message : "error:something.wrong");
+ showBlahNotification(e.message || e);
+ pendingRotationMessagePayload = null;
}
}
@@ -2145,7 +2128,7 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
- let displayName = username.split(':')[0] || onlyId;
+ let displayName = username || onlyId;
if (type === "received") {
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = `
@@ -2199,7 +2182,7 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
- let displayName = username.split(':')[0] || onlyId;
+ let displayName = username || onlyId;
let val1 = `
${displayName}`;
let val2 = `
${onlyId}`;
@@ -2403,6 +2386,13 @@ function gotoJoinSpace() {
}
async function gotoHome(splash = true) {
+ if (dmMessagePollInterval) {
+ clearInterval(dmMessagePollInterval);
+ dmMessagePollInterval = null;
+ }
+ currentDmId = null;
+ window.currentRoomType = null;
+
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarHomeIndicator);
if (splash) {
@@ -2595,6 +2585,8 @@ async function renderDms(res) {
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
+ let unreadBadge = `
`;
+
html += `
+ ${unreadBadge}
`;
count++;
@@ -2655,6 +2648,8 @@ async function renderGroups(res) {
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName);
+ let unreadBadge = ``;
+
html += `
`;
count++;
@@ -2766,9 +2762,63 @@ async function inviteToGroupPrompt(groupId) {
try {
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({
string1: groupId,
- string2: targetId
+ string2: targetId,
+ string3: keysPayload
});
let res = await fetchEncrypted("group/invite", payload);
@@ -2861,10 +2911,10 @@ async function openDm(dmId, username, targetId, type = 'dms') {
let iconHtml = "";
if (type === 'dms') {
let pfp = await getAvatarUrl(targetId, username);
- iconHtml = `
`;
+ iconHtml = `
`;
} else {
let pfp = await getGroupAvatarUrl(dmId, username);
- iconHtml = `
`;
+ iconHtml = `
`;
}
if (currentRoomsBarTitle !== "title.home") {
@@ -3022,8 +3072,27 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
if (atts.length > 0) {
attachmentsHtml += ``;
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 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 displayName = truncateFilename(att.name, 25);
let iconSvg = isMedia
@@ -3528,7 +3597,10 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name,
}
} catch (e) {
console.error(e);
- container.innerHTML = `
Error loading
`;
+ if (window.currentRoomType === 'groups' && window.groupKeysCache[currentDmId]) {
+ delete window.groupKeysCache[currentDmId][keyIndex];
+ }
+ container.innerHTML = `
Error loading - click to retry
`;
container.dataset.loading = "false";
}
}
@@ -3655,12 +3727,17 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
}
if (decText && decText.startsWith("success:")) {
- attachmentsPayload.push({
- id: i,
+ let metadataObj = {
type: file.type,
name: file.name,
- size: file.size,
- staging_id: decText.split(":")[1]
+ size: file.size
+ };
+ let encMetadata = "ENC:" + await encryptAesGcmToBase64(JSON.stringify(metadataObj), dmKeyBytes);
+
+ attachmentsPayload.push({
+ id: i,
+ staging_id: decText.split(":")[1],
+ enc: encMetadata
});
} else {
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') {
msgPayload.string5 = window.currentGroupKeyIndex || "0";
- msgPayload.rawPlainContent = content; // for retry
}
+ let savedContentForRestore = content;
+ let savedAttachmentsForRestore = [...currentDraftAttachments];
+
input.value = "";
input.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') {
let pElem = document.getElementById(pendingMsgId);
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:")) {
showBlahNotification(res);
let pElem = document.getElementById(pendingMsgId);
@@ -4365,8 +4450,8 @@ function initCropper() {
let zoomSlider = document.getElementById('cropper-zoom');
// Set aspect ratio
- let targetWidth = cropperType === 'avatar' ? 512 : 494;
- let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
+ let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 494;
+ let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? targetWidth : 200;
let aspectRatio = targetWidth / targetHeight;
container.style.height = 'auto';
@@ -4434,8 +4519,8 @@ async function confirmCropper() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let container = document.getElementById('cropper-viewport-container');
- let targetWidth = cropperType === 'avatar' ? 512 : 988;
- let targetHeight = cropperType === 'avatar' ? 512 : 400;
+ let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 988;
+ let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 400;
let scaleRatio = targetWidth / container.clientWidth;
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
@@ -4705,6 +4790,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
if (nameSpan) {
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) {
container.prepend(btn);
}
@@ -4737,6 +4827,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
if (nameSpan) {
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) {
container.prepend(btn);
}
diff --git a/webroot/style.css b/webroot/style.css
index 23c4e5ac..94ec00ed 100644
--- a/webroot/style.css
+++ b/webroot/style.css
@@ -1145,3 +1145,26 @@ input:checked + .toggle-slider:before {
color: var(--darker-text-color);
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); }
+}