bug fixes & ui improvements
All checks were successful
Android Build / publish (push) Successful in 53s
Linux Build / publish (push) Successful in 1m1s

This commit is contained in:
olcxja 2026-08-10 02:17:56 +02:00
commit 55760ba368
4 changed files with 308 additions and 72 deletions

View file

@ -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);
} }

View file

@ -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); }
}

View file

@ -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);
} }

View file

@ -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); }
}