add groups (TEST)
All checks were successful
Android Build / publish (push) Successful in 57s
Linux Build / publish (push) Successful in 1m3s

This commit is contained in:
olcxja 2026-08-01 15:19:27 +02:00
commit 903aedc8ce
14 changed files with 3104 additions and 1402 deletions

View file

@ -10,6 +10,8 @@
"dm.accepted": "direct meowchat accepted",
"no.invite.found": "you can't create a dm without invitation *hiss*",
"unknown.error": "unknown error :o",
"missing.keys": "cannot rotate nametags: some kitties don't have public nametags available yet. they must set up their identity.",
"key.rotated": "meowchat keys have been successfully rotated! meow!",
"forbidden": "you do not have pawmission to perform this action *hiss*",
"dm.key.too.large": "meowchat key too large :o",
"dm.key.not.found": "meowchat key not found :c",
@ -125,6 +127,7 @@
"desc.messages.loading": "loading meows...",
"desc.no.dms": "no direct meowchats :c",
"action.dm.opening": "opening meowchat...",
"action.group.opening": "opening clowder...",
"dm.open.failed": "failed to open meowchat :c",
"keys.local.server.mismatch": "this device has different nametags than server :c use the device u first logged in on",
"keys.server.decrypt.failed": "wrong password for ur secret nametag bundle :c",
@ -201,5 +204,58 @@
"message.unpinned.by": "{0} unpinned a {1}.",
"title.pinned.messages": "Pinned",
"error.pinned.messages.empty": "No pinned meows",
"title.message.lowercase": "meowsage"
"title.message.lowercase": "meowsage",
"action.rotate.keys": "Meowtate Keys",
"already.in.group": "Meowready in group",
"desc.key.rotation.info": "Meow need to purrotate meowcription keys to purrtinue sending meowsages in this group. Other meowmbers will autopurrtically receive the new key.",
"desc.no.groups": "Meow are not in any groups yet.",
"dm.read": "Purread",
"empty.body": "Empty meowdy",
"failed.create.group": "Failed to purreate group",
"federation.not.implemented": "Meowderation not implemeowted",
"group.not.found": "Group not meownd",
"group.read": "Purread",
"invalid.entry.name": "Invalid purrtry name",
"invalid.key.index": "Invalid key index (purrlease purrefresh the page)",
"invalid.msg.id": "Invalid meowsage ID",
"invalid.payload": "Invalid purrload",
"invalid.reaction.id": "Invalid purreaction ID",
"invalid.user": "Invalid meowser",
"joined": "Purroined",
"key.rotation.required": "Key purrotation meowquired",
"key.too.large": "Key too meowrge",
"keys.public.mismatch": "Public keys mismemeow",
"label.members": "MEOWMBERS",
"message.deleted": "Meowsage purrleted",
"message.not.found": "Meowsage not meownd",
"message.pin.toggled": "Meowsage purrin purrggled",
"message.reacted": "Meowsage purreacted",
"message.reaction.removed": "Purreaction remeowed",
"no.invite": "No meowvite",
"not.a.member": "Not a meowmber",
"not.in.group": "Not in purroup",
"reaction.not.found": "Purreaction not meownd",
"rotation.not.required": "Purrotation not meowquired",
"storage.entry.updated": "Storage purrtry updated",
"storage.limit.exceeded": "Storage limit exmeowded",
"title.key.rotation": "Key Purrotation",
"error:messages.decrypt.failed": "Depurryption meowled",
"error.message.already.deleted": "Meowsage alpurrdy meowleted",
"group.user.joined.by": "{0} purroined the group",
"group.created.by": "Group purreated by {0}",
"group.user.left.by": "{0} left the purroup",
"action.group.fetch": "Meowtching groups",
"group.created": "Group purreated",
"group.user.joined": "Purroined group",
"prompt.invite.username": "Enter username to invite meow:",
"desc.failed.load.members": "Failed to load members meow",
"prompt.are.you.sure": "Are you sure meow?",
"title.unknown.group": "Unknown Group meow",
"title.unknown": "Unknown meow",
"en-us": "English meow",
"pl-pl": "Polish meow",
"en-cat": "Meowlish",
"avatar.updated": "Avatar updated successfully",
"profile.updated": "Profile updated successfully",
"banner.updated": "Banner updated successfully"
}

View file

@ -10,6 +10,8 @@
"dm.accepted": "DM accepted",
"no.invite.found": "You can't create a dm without invitation",
"unknown.error": "Unknown error",
"missing.keys": "Cannot rotate keys: Some members don't have public keys available yet. They must configure their identity.",
"key.rotated": "Group keys have been successfully rotated.",
"forbidden": "You do not have permission to perform this action",
"dm.key.too.large": "DM key too large",
"dm.key.not.found": "DM key not found",
@ -124,6 +126,7 @@
"desc.messages.loading": "Fetching messages...",
"desc.no.dms": "No direct messages",
"action.dm.opening": "Opening DM...",
"action.group.opening": "Opening group...",
"dm.open.failed": "Failed to open DM",
"keys.local.server.mismatch": "This device has different encryption keys than the server. Use the device where you first logged in, or restore server keys from backup.",
"keys.server.decrypt.failed": "Could not decrypt your account keys with this password.",
@ -201,5 +204,58 @@
"message.unpinned.by": "{0} unpinned a {1}.",
"title.pinned.messages": "Pinned",
"error.pinned.messages.empty": "No pinned messages",
"title.message.lowercase": "message"
"title.message.lowercase": "message",
"action.rotate.keys": "Rotate Keys",
"already.in.group": "Already in group",
"desc.key.rotation.info": "You need to rotate encryption keys to continue sending messages in this group. Other members will automatically receive the new key.",
"desc.no.groups": "You are not in any groups yet.",
"dm.read": "Read",
"empty.body": "Empty body",
"failed.create.group": "Failed to create group",
"federation.not.implemented": "Federation not implemented",
"group.not.found": "Group not found",
"group.read": "Read",
"invalid.entry.name": "Invalid entry name",
"invalid.key.index": "Invalid key index (please refresh the page)",
"invalid.msg.id": "Invalid message ID",
"invalid.payload": "Invalid payload",
"invalid.reaction.id": "Invalid reaction ID",
"invalid.user": "Invalid user",
"joined": "Joined",
"key.rotation.required": "Key rotation required",
"key.too.large": "Key too large",
"keys.public.mismatch": "Public keys mismatch",
"label.members": "MEMBERS",
"message.deleted": "Message deleted",
"message.not.found": "Message not found",
"message.pin.toggled": "Message pin toggled",
"message.reacted": "Message reacted",
"message.reaction.removed": "Reaction removed",
"no.invite": "No invite",
"not.a.member": "Not a member",
"not.in.group": "Not in group",
"reaction.not.found": "Reaction not found",
"rotation.not.required": "Rotation not required",
"storage.entry.updated": "Storage entry updated",
"storage.limit.exceeded": "Storage limit exceeded",
"title.key.rotation": "Key Rotation",
"error:messages.decrypt.failed": "Decryption failed",
"error.message.already.deleted": "Message already deleted",
"group.user.joined.by": "{0} joined the group",
"group.created.by": "Group created by {0}",
"group.user.left.by": "{0} left the group",
"action.group.fetch": "Fetching groups",
"group.created": "Group created",
"group.user.joined": "Joined group",
"prompt.invite.username": "Enter username to invite:",
"desc.failed.load.members": "Failed to load members",
"prompt.are.you.sure": "Are you sure?",
"title.unknown.group": "Unknown Group",
"title.unknown": "Unknown",
"success:banner.updated": "Banner updated successfully",
"success:profile.updated": "Profile updated successfully",
"success:avatar.updated": "Avatar updated successfully",
"en-us": "English",
"pl-pl": "Polish",
"en-cat": "Meowlish"
}

View file

@ -124,6 +124,7 @@
"desc.messages.loading": "Pobieranie wiadomości...",
"desc.no.dms": "Brak wiadomości prywatnych",
"action.dm.opening": "Otwieranie rozmowy...",
"action.group.opening": "Otwieranie grupy...",
"dm.open.failed": "Nie udało się otworzyć rozmowy",
"keys.local.server.mismatch": "To urządzenie ma inne klucze szyfrowania niż serwer. Użyj urządzenia, na którym zalogowano się po raz pierwszy, lub przywróć klucze z kopii zapasowej.",
"keys.server.decrypt.failed": "Nie udało się odszyfrować kluczy konta przy użyciu tego hasła.",
@ -201,5 +202,60 @@
"message.unpinned.by": "{0} odpiął {1}.",
"title.pinned.messages": "Przypięte",
"error.pinned.messages.empty": "Brak przypiętych wiadomości",
"title.message.lowercase": "wiadomość"
"title.message.lowercase": "wiadomość",
"action.rotate.keys": "Rotuj Klucze",
"already.in.group": "Już w grupie",
"desc.key.rotation.info": "Musisz zrotować klucze szyfrujące, aby kontynuować wysyłanie wiadomości w tej grupie. Inni członkowie automatycznie otrzymają nowy klucz.",
"desc.no.groups": "Nie jesteś jeszcze w żadnej grupie.",
"dm.read": "Przeczytano",
"empty.body": "Pusta zawartość",
"failed.create.group": "Nie udało się utworzyć grupy",
"federation.not.implemented": "Federacja niezaimplementowana",
"group.not.found": "Nie znaleziono grupy",
"group.read": "Przeczytano",
"invalid.entry.name": "Nieprawidłowa nazwa wpisu",
"invalid.key.index": "Nieprawidłowy indeks klucza (odśwież stronę)",
"invalid.msg.id": "Nieprawidłowe ID wiadomości",
"invalid.payload": "Nieprawidłowy ładunek",
"invalid.reaction.id": "Nieprawidłowe ID reakcji",
"invalid.user": "Nieprawidłowy użytkownik",
"joined": "Dołączono",
"key.rotation.required": "Wymagana rotacja kluczy",
"key.too.large": "Klucz zbyt duży",
"keys.public.mismatch": "Niezgodność kluczy publicznych",
"label.members": "CZŁONKOWIE",
"message.deleted": "Wiadomość usunięta",
"message.not.found": "Nie znaleziono wiadomości",
"message.pin.toggled": "Zmieniono przypięcie wiadomości",
"message.reacted": "Dodano reakcję",
"message.reaction.removed": "Reakcja usunięta",
"no.invite": "Brak zaproszenia",
"not.a.member": "Nie jesteś członkiem",
"not.in.group": "Nie jesteś w grupie",
"reaction.not.found": "Nie znaleziono reakcji",
"rotation.not.required": "Rotacja niewymagana",
"storage.entry.updated": "Zaktualizowano wpis pamięci",
"storage.limit.exceeded": "Przekroczono limit pamięci",
"title.key.rotation": "Rotacja kluczy",
"missing.keys": "Brakujące klucze",
"error:messages.decrypt.failed": "Deszyfrowanie nie powiodło się",
"error.message.already.deleted": "Wiadomość została już usunięta",
"group.user.joined.by": "{0} dołączył do grupy",
"group.created.by": "Grupa utworzona przez {0}",
"group.user.left.by": "{0} opuścił grupę",
"action.group.fetch": "Pobieranie grup",
"group.created": "Utworzono grupę",
"group.user.joined": "Dołączono do grupy",
"prompt.invite.username": "Podaj nazwę użytkownika, aby zaprosić:",
"desc.failed.load.members": "Nie udało się załadować członków",
"prompt.are.you.sure": "Czy na pewno?",
"title.unknown.group": "Nieznana grupa",
"title.unknown": "Nieznany",
"key.rotated": "Klucze grupy zostały pomyślnie zrotowane.",
"success:banner.updated": "Baner został pomyślnie zaktualizowany",
"success:profile.updated": "Profil został pomyślnie zaktualizowany",
"success:avatar.updated": "Awatar został pomyślnie zaktualizowany",
"en-us": "Angielski",
"pl-pl": "Polski",
"en-cat": "Koci (Meowlish)"
}

View file

@ -119,6 +119,25 @@
</div>
</div>
</div>
<div id="rotation-popup-container" class="profile-popup-container" style="display: none; z-index: 9999;">
<div class="profile-popup-bg" onclick="closeRotationPopup()"></div>
<div class="profile-popup-content">
<button class="profile-popup-close-btn" onclick="closeRotationPopup()">
<script>
document.currentScript.insertAdjacentHTML('beforebegin', ICONS.close());
document.currentScript.remove();
</script>
</button>
<div id="profile-popup-inner" style="padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem;">
<div style="font-size: 1.2rem; font-weight: 500; text-align: center;"><blah>title.key.rotation</blah></div>
<div style="width: 100%; text-align: center; color: var(--text-color); opacity: 0.8; margin-top: 1rem; margin-bottom: 1rem;" id="rotation-popup-desc">
<!-- content injected dynamically -->
</div>
<button class="profile-action-btn" onclick="executeKeyRotation()"><blah>action.rotate.keys</blah></button>
</div>
</div>
</div>
</body>
</html>
@ -208,8 +227,59 @@
}
}
async function createGroup() {
async function refreshGroups(waittime = 0, showLoading = true) {
try {
if (showLoading) showAction("action.group.fetch", "grouprefresh");
await delay(waittime);
if (window.cachedGroups && typeof renderGroups === 'function') {
await renderGroups(window.cachedGroups);
}
let res = await fetchEncrypted("user/group/list");
window.cachedGroups = res;
if (typeof renderGroups === 'function') {
await renderGroups(res);
}
if (showLoading) clearAction("grouprefresh");
}
catch (e) {
if (showLoading) clearAction("grouprefresh");
showBlahNotification("error:group.refresh.failed");
}
}
async function createGroup() {
try {
let groupName = document.getElementById("creategroup-name").value;
if (!groupName || groupName.trim() === "") {
showBlahNotification("error:group.name.empty");
return;
}
showAction("action.group.creating", "group.action");
await ensureUserKeys();
const roomKey = randomRoomKey256Chars();
const selfWrapped = await wrapRoomKeyForSelf(roomKey);
let payload = JSON.stringify({
string1: groupName,
string2: selfWrapped
});
let res = await fetchEncrypted("group/create", payload);
clearAction("group.action");
if (res.startsWith("success:")) {
showBlahNotification("success:group.created");
await refreshGroups(200, false);
} else {
showBlahNotification(res || "error:something.wrong");
}
} catch (e) {
clearAction("group.action");
showBlahNotification("error:something.wrong");
console.error(e);
}
}
</script>
<script src="gifuct.js"></script>

View file

@ -367,6 +367,171 @@ async function ensureDmRoomKey(dmId) {
window.ensureDmRoomKey = ensureDmRoomKey;
window.groupKeysCache = {};
async function getGroupKey(groupId, keyIndex) {
if (!window.groupKeysCache[groupId]) {
window.groupKeysCache[groupId] = {};
}
if (window.groupKeysCache[groupId][keyIndex]) {
return window.groupKeysCache[groupId][keyIndex];
}
let payload = JSON.stringify({string1: groupId, string2: keyIndex.toString()});
let res = await fetchEncrypted("group/key/get", payload);
if (!res || res.startsWith("error:")) {
return null;
}
try {
let decryptedKey;
if (res.startsWith("SETUP:")) {
const rest = res.substring("SETUP:".length);
const parts = rest.split(";");
if (parts.length < 2) throw new Error("error:group.key.setup.malformed");
const otherPublicJson = parts[0];
const setupPayloadJson = parts.slice(1).join(";");
const otherPublic = JSON.parse(otherPublicJson);
const setupPayload = JSON.parse(setupPayloadJson);
const aesKeyBytes = await deriveHybridKeyFromSetup(otherPublic, setupPayload.ctMlKem);
decryptedKey = await decryptAesGcmFromBase64(setupPayload.enc, aesKeyBytes);
} else {
decryptedKey = await unwrapRoomKeyForSelf(res);
}
window.groupKeysCache[groupId][keyIndex] = decryptedKey;
return decryptedKey;
} catch(e) {
return null;
}
}
function closeRotationPopup() {
let popupContainer = document.getElementById("rotation-popup-container");
if(popupContainer) {
popupContainer.classList.remove("show");
setTimeout(() => {
popupContainer.style.display = "none";
}, 200);
}
clearAction("key_rotation");
}
let pendingRotationMessagePayload = null;
async function showRotationPopup(groupId, originalMessagePayload) {
pendingRotationMessagePayload = originalMessagePayload;
showAction("action.key.rotation", "key_rotation");
try {
let membersRaw = await fetchEncrypted("group/members", groupId);
if(membersRaw && membersRaw.startsWith("error:")) throw new Error(membersRaw);
let members = JSON.parse(membersRaw);
let desc = document.getElementById("rotation-popup-desc");
desc.innerHTML = `<blah>desc.key.rotation.info</blah><br><br><b>${members.length} <blah>label.members</blah></b>`;
let blahTags = desc.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
let popupContainer = document.getElementById("rotation-popup-container");
popupContainer.style.display = "flex";
popupContainer.offsetHeight; // trigger reflow
popupContainer.classList.add("show");
} catch(e) {
clearAction("key_rotation");
showBlahNotification("error:something.wrong");
}
}
async function executeKeyRotation() {
closeRotationPopup();
showAction("action.key.rotating", "key_rotation_exec");
try {
let groupId = currentDmId;
let membersRaw = await fetchEncrypted("group/members", groupId);
if(membersRaw && membersRaw.startsWith("error:")) throw new Error(membersRaw);
let members = JSON.parse(membersRaw);
let roomKey = randomRoomKey256Chars();
let keysDict = {};
let myPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${window.id}`);
for(let memberId of members) {
let pureId = memberId.split(";")[0];
if(pureId === window.id) {
keysDict[memberId] = await wrapRoomKeyForSelf(roomKey);
} else {
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${pureId}`);
if(publicKeysRaw && publicKeysRaw.length > 10) {
let publicKeys = JSON.parse(publicKeysRaw);
let hybrid = await deriveHybridKeyToUser(publicKeys);
let encForMember = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
let setupPayloadJson = JSON.stringify({
v: 1,
alg: "hybrid-x25519-mlkem768",
ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForMember
});
keysDict[memberId] = `SETUP:${myPublicKeysRaw};${setupPayloadJson}`;
} else {
throw new Error("error:missing.keys");
}
}
}
let payload = JSON.stringify({
groupId: groupId,
keys: keysDict
});
let res = await fetchEncrypted("group/key/rotate", payload);
if(res && res.startsWith("error:")) {
throw new Error(res);
}
// Success!
let newIndex = res.replace("success:", "");
window.currentGroupKeyIndex = newIndex;
if(!window.groupKeysCache[groupId]) window.groupKeysCache[groupId] = {};
window.groupKeysCache[groupId][newIndex] = roomKey;
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");
}
}
async function encrypt(plainText, keyBytes) {
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const encoder = new TextEncoder();
@ -857,6 +1022,18 @@ async function getAvatarUrl(id, username) {
}
}
async function getGroupAvatarUrl(groupId, groupName) {
try {
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
if ((await fetchAsync(pfpUrl)) == "") {
throw Error();
}
return pfpUrl;
} catch (e) {
return createAvatarSvg(groupName);
}
}
function updateLoadingStatus(message) {
loadingStatus.innerHTML = processBlah(message);
}
@ -1743,6 +1920,71 @@ function setActiveRoombarItem(itemId) {
}
}
async function acceptGroupInvite(groupId, inviterId) {
try {
showAction("action.invite.accepting", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: inviterId
});
let res = await fetchEncrypted("group/join", payload);
clearAction("invite.action");
showBlahNotification(res || "success:joined");
// Refresh invites and groups
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("received");
if (typeof refreshGroups === 'function') refreshGroups(0, false);
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function declineGroupInvite(groupId, inviterId) {
try {
showAction("action.invite.declining", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: inviterId
});
let res = await fetchEncrypted("group/invite/decline", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.declined");
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("received");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function revokeGroupInvite(groupId, targetId) {
try {
showAction("action.invite.revoking", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: targetId
});
let res = await fetchEncrypted("group/invite/revoke", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.revoked");
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("sent");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function renderInvites(res, type) {
let container = document.getElementById("invites-container");
if (!container) return;
@ -1781,7 +2023,7 @@ async function renderInvites(res, type) {
}
let onlyId = id.split(":")[0];
let username = await fetchAsync(`${url}/idtoname?id=${id}`);
if (!(username && username.trim() !== "")) return;
if (!(username && username.trim() !== "")) continue;
count++;
let pfp = await getAvatarUrl(id, username);
@ -1789,8 +2031,9 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
let displayName = username.split(':')[0] || onlyId;
if (type === "received") {
desc = `:desc.invite.dm.received:${username};${onlyId}`;
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = `
<button class="icon-button" style="background-color: var(--big-green);" onclick="acceptInvite('${id}')">
${ICONS.check({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
@ -1800,7 +2043,7 @@ async function renderInvites(res, type) {
</button>
`;
} else {
desc = `:desc.invite.dm.sent:${username};${onlyId}`;
desc = `:desc.invite.dm.sent:${displayName};${onlyId}`;
actions = `
<button class="icon-button" style="background-color: var(--big-red);" onclick="revokeInvite('${id}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
@ -1809,12 +2052,70 @@ async function renderInvites(res, type) {
}
let entry = invitesEntry
.replaceAll("{username}", username.split(':')[0])
.replaceAll("{username}", displayName)
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", desc)
.replaceAll("{desc}", `<blah>${desc}</blah>`)
.replaceAll("{actions}", actions)
.replaceAll("{onclick_attr}", `onclick="openProfile('${id}')" style="cursor: pointer;"`)
.replaceAll("{id}", id);
html += entry;
} else if (invite.type === "group") {
let groupId = invite.id;
let targetId = (type === "received") ? invite.sender : invite.receiver;
if (!targetId.includes(":")) {
targetId += `:${host}`;
}
let onlyId = targetId.split(":")[0];
let username = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!(username && username.trim() !== "")) continue;
// Fetch group name
let groupInfoStr = await fetchEncrypted("group/info", groupId);
let groupName = processBlah("title.unknown.group");
if (groupInfoStr && !groupInfoStr.startsWith("error")) {
let groupInfo = JSON.parse(groupInfoStr);
groupName = groupInfo.string2;
}
count++;
let pfp = await getGroupAvatarUrl(groupId, groupName);
let actions = "";
let desc = "";
let displayName = username.split(':')[0] || onlyId;
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>`;
if (type === "received") {
desc = `:desc.invite.group.received:${val1};${val2}`;
actions = `
<button class="icon-button" style="background-color: var(--big-green);" onclick="acceptGroupInvite('${groupId}', '${targetId}')">
${ICONS.check({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
<button class="icon-button" style="background-color: var(--big-red);" onclick="declineGroupInvite('${groupId}', '${targetId}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
`;
} else {
desc = `:desc.invite.group.sent:${val1};${val2}`;
actions = `
<button class="icon-button" style="background-color: var(--big-red);" onclick="revokeGroupInvite('${groupId}', '${targetId}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
`;
}
let entry = invitesEntry
.replaceAll("{username}", groupName)
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", `<blah>${desc}</blah>`)
.replaceAll("{actions}", actions)
.replaceAll("{onclick_attr}", "")
.replaceAll("{id}", groupId);
html += entry;
}
}
@ -1996,7 +2297,10 @@ async function gotoHome(splash = true) {
switchRoomsBar("title.home", homeRoomBar);
setActiveRoombarItem(null);
setupWebSocket();
await refreshDms(210);
await Promise.all([
refreshDms(210),
(typeof refreshGroups === 'function' ? refreshGroups(210) : Promise.resolve())
]);
}
function setActiveSidebarIndicator(element, isTemporary = false) {
@ -2210,6 +2514,67 @@ async function renderDms(res) {
}
}
async function renderGroups(res) {
let container = document.getElementById("groups-list");
if (!container) return;
if (res && res !== "") {
let parsed = JSON.parse(res);
let groups = parsed.groups || {};
let groupEntries = Object.entries(groups);
//Sort by timestamp descending
groupEntries.sort((a, b) => (parseInt(b[1].string2) || 0) - (parseInt(a[1].string2) || 0));
let html = "";
let count = 0;
for (let [groupId, groupData] of groupEntries) {
let isRead = groupData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && infoRes.startsWith("{")) {
let info = JSON.parse(infoRes);
let groupName = info.string2;
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName);
html += `
<button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')">
<img src="${pfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<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>
</div>
</button>
`;
count++;
}
}
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
} else {
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
}
} else {
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
}
}
var currentDmId = null;
var currentDmKey = null; // AES key for encrypting/decrypting messages
var dmUsernameCache = {};
@ -2263,30 +2628,131 @@ async function generateDmMembersHtml(targetId, targetUsername) {
`;
}
async function openDm(dmId, username, targetId) {
async function inviteToGroupPrompt(groupId) {
let targetUsername = prompt(processBlah("prompt.invite.username"));
if (!targetUsername) return;
if (!targetUsername.includes(":")) {
targetUsername += `:${host}`;
}
let idRes = await fetchAsync(`${url}/nametoid?u=${targetUsername}`);
if (idRes.startsWith("error") || idRes.trim() === "0" || idRes.trim() === "") {
showBlahNotification("error:user.not.found");
return;
}
let targetId = idRes.trim();
try {
showAction("action.invite.sending", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: targetId
});
let res = await fetchEncrypted("group/invite", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.sent");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function generateGroupMembersHtml(groupId, groupName) {
let membersJson = await fetchEncrypted("group/members", groupId);
if (!membersJson || membersJson.startsWith("error")) {
return `<div style="text-align: center; opacity: 0.5; padding: 1rem;"><blah>desc.failed.load.members</blah></div>`;
}
let members = JSON.parse(membersJson);
let html = `
<div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;">
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%; display: flex; align-items: center;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
<div class="flex-spacer"></div>
<button class="topbar-action-btn" onclick="inviteToGroupPrompt('${groupId}')" style="margin-right: 0.5rem; width: 1.2rem; height: 1.2rem; display: flex; align-items: center; justify-content: center; opacity: 0.7;">
${ICONS.plus({ width: "1rem", height: "1rem" })}
</button>
</div>`;
for (let memberId of members) {
let memberUsername = await fetchAsync(`${url}/idtoname?id=${memberId}`);
if (!memberUsername || memberUsername.startsWith("error")) continue;
let memberPfp = await getAvatarUrl(memberId, memberUsername);
let memberDisplayName = memberUsername.split(':')[0];
html += `
<button class="room-entry" onclick="openProfile('${memberId}')" style="margin-bottom: 0;">
<img src="${memberPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${memberDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${memberUsername}</span>
</div>
</button>`;
}
html += `</div>`;
return html;
}
async function openDm(dmId, username, targetId, type = 'dms') {
try {
showAction("action.dm.opening", "dmopen");
markDmAsRead(dmId);
if (type === 'groups') {
markDmAsRead(dmId, 'groups');
} else {
markDmAsRead(dmId, 'dms');
}
if (type === 'dms') {
currentDmKey = await ensureDmRoomKey(dmId);
if (!currentDmKey) {
throw new Error("Missing DM room key");
}
} else {
let infoPayload = await fetchEncrypted("group/info", dmId);
if (infoPayload && !infoPayload.startsWith("error:")) {
let info = JSON.parse(infoPayload);
window.currentGroupKeyIndex = info.string4 || "0";
} else {
window.currentGroupKeyIndex = "0";
}
}
if (currentDmId !== dmId) {
currentRoomContentTitle = "";
}
currentDmId = dmId;
window.currentRoomType = type; // Save the type globally
loadedMessages = {};
oldestLoadedMsgId = null;
isLoadingOlderMessages = false;
let iconHtml = "";
if (type === 'dms') {
let pfp = await getAvatarUrl(targetId, username);
let 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; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
} else {
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);">`;
}
if (currentRoomsBarTitle !== "title.home") {
gotoHome(false);
}
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
let membersHtml = await generateDmMembersHtml(targetId, username);
let membersHtml = "";
if (type === 'dms') {
membersHtml = await generateDmMembersHtml(targetId, username);
} else {
membersHtml = await generateGroupMembersHtml(dmId, username);
}
await switchDetailsContent("title.details", membersHtml);
let msgContainer = document.getElementById("chat-messages");
@ -2326,7 +2792,8 @@ async function openDm(dmId, username, targetId) {
async function loadDmMessages(dmId, startOffset = "", isPrepend = false) {
try {
let payload = JSON.stringify({string1: dmId, string2: "50", string3: startOffset});
let res = await fetchEncrypted("dm/messages/get", payload);
let endpoint = window.currentRoomType === 'groups' ? "group/messages/get" : "dm/messages/get";
let res = await fetchEncrypted(endpoint, payload);
if (res && res.startsWith("error:")) {
showBlahNotification(res);
@ -2389,9 +2856,26 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let decrypted = false;
if (msg.key && msg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
content = await decryptAesGcmFromBase64(content, dmKeyBytes);
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
keyToUse = await getGroupKey(currentDmId, msg.key);
if (keyToUse) {
let mK = parseInt(msg.key);
let cK = parseInt(window.currentGroupKeyIndex || "0");
if (!isNaN(mK) && !isNaN(cK) && mK > cK) {
window.currentGroupKeyIndex = msg.key;
}
}
}
if (!keyToUse) {
content = `<span style="color:var(--big-red)"><blah>error:messages.decrypt.failed</blah></span>`;
decrypted = false;
} else {
let keyBytes = await sha256Bytes(base64ToUint8(keyToUse));
content = await decryptAesGcmFromBase64(content, keyBytes);
decrypted = true;
}
} catch (e) {
content = `<span style="color:var(--big-red)"><blah>error:messages.decrypt.failed</blah></span>`;
decrypted = false;
@ -2423,7 +2907,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let safeType = encodeURIComponent(att.type).replace(/'/g, "%27");
let safeName = encodeURIComponent(att.name).replace(/'/g, "%27");
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'))">
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'), '${msg.key || "0"}')">
<div style="padding: 1rem; text-align: center; color: var(--text-color);">
${iconSvg}
<div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div>
@ -2454,8 +2938,8 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
content = processBlah(content);
if (msg.type && msg.type.startsWith("larp.info")) {
if (msg.type === "larp.info.pin" || msg.type === "larp.info.unpin") {
let authorName = "Unknown";
if (msg.type === "larp.info.pin" || msg.type === "larp.info.unpin" || msg.type === "larp.info.join" || msg.type === "larp.info.leave" || msg.type === "larp.info.create") {
let authorName = processBlah("title.unknown");
if (msg.author && msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
let fullUsername = await fetchAsync(`${url}/idtoname?id=${msg.author}`);
@ -2471,14 +2955,22 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
}
}
let isPinned = msg.type === "larp.info.pin";
let actionStr = isPinned ? "message.pinned.by" : "message.unpinned.by";
let actionStr = "";
if (msg.type === "larp.info.pin") actionStr = "message.pinned.by";
else if (msg.type === "larp.info.unpin") actionStr = "message.unpinned.by";
else if (msg.type === "larp.info.join") actionStr = "group.user.joined.by";
else if (msg.type === "larp.info.leave") actionStr = "group.user.left.by";
else if (msg.type === "larp.info.create") actionStr = "group.created.by";
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${msg.pervious}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
let textStr = processBlah(actionStr);
if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) {
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${msg.pervious}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg);
} else {
content = textStr.replace("{0}", clickableAuthor);
}
}
html += `<div style="text-align: center; opacity: 0.5; padding: 1rem; font-size: 0.9rem; word-break: break-word; overflow-wrap: anywhere;">${content}</div>`;
lastAuthor = null;
@ -2501,7 +2993,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
}
lastAuthor = msg.author;
let authorName = "Unknown";
let authorName = processBlah("title.unknown");
let pfp = "assets/default_avatar.png";
if (msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
@ -2618,8 +3110,15 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let rxVal = rx.r;
if (msg.key && msg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, msg.key);
if (k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
rxVal = await decryptAesGcmFromBase64(rxVal, dmKeyBytes);
}
} catch(e) {}
}
if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false, myReactionId: null};
@ -2839,8 +3338,8 @@ function setupChatScrollListener() {
async function downloadAndRenderAttachment(container, msgId, attId, type, name) {
if (!currentDmId || !currentDmKey) return;
async function downloadAndRenderAttachment(container, msgId, attId, type, name, keyIndex = "0") {
if (!currentDmId || (window.currentRoomType === 'dms' && !currentDmKey)) return;
if (!container) return;
if (container.dataset.loaded === "true") {
@ -2860,10 +3359,16 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name)
container.innerHTML = `<div style="padding: 1rem; color: var(--text-color);">${ICONS.spinner()}</div>`;
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, keyIndex);
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let nonce = await getNonce(id, passwordHash);
let res = await fetch(`${url}/dm/attachment/get?id=${id}&dm=${currentDmId}&msg=${msgId}&att=${attId}`, {
let endpointType = window.currentRoomType === 'groups' ? "group" : "dm";
let res = await fetch(`${url}/${endpointType}/attachment/get?id=${id}&dm=${currentDmId}&msg=${msgId}&att=${attId}`, {
headers: {
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
}
@ -2953,7 +3458,7 @@ function renderAttachmentsPreview() {
}
async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) {
if (!currentDmId || !currentDmKey) return;
if (!currentDmId || (window.currentRoomType === 'dms' && !currentDmKey)) return;
let input = document.getElementById("chat-input");
let content = input.value.trim();
@ -2975,7 +3480,13 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
}
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, window.currentGroupKeyIndex || "0");
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes);
let attachmentsPayload = [];
@ -3036,6 +3547,11 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
string4: replyingToMsgId || ""
};
if (window.currentRoomType === 'groups') {
msgPayload.string5 = window.currentGroupKeyIndex || "0";
msgPayload.rawPlainContent = content; // for retry
}
input.value = "";
input.style.height = '2.5rem';
sendbutton.style.height = '2.5rem';
@ -3049,9 +3565,14 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
showAction("action.message.sending", "msgsending");
let res = await fetchEncrypted("dm/message/send", JSON.stringify(msgPayload));
let res = await fetchEncrypted(window.currentRoomType === 'groups' ? "group/message/send" : "dm/message/send", JSON.stringify(msgPayload));
clearAction("msgsending");
if (res && res.startsWith("error:")) {
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
showRotationPopup(currentDmId, msgPayload);
} else if (res && res.startsWith("error:")) {
showBlahNotification(res);
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
@ -3073,7 +3594,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
type: "larp.text",
content: encryptedContent,
attachment: finalAttPayload,
key: "0",
key: window.currentRoomType === 'groups' ? (window.currentGroupKeyIndex || "0") : "0",
pervious: "",
responded: replyingToMsgId || "",
reactions: ""
@ -3148,15 +3669,16 @@ function setupWebSocket() {
appWebSocket.onmessage = (event) => {
let data = event.data;
if (data.startsWith("dm_message:")) {
if (data.startsWith("dm_message:") || data.startsWith("group_message:")) {
let parts = data.split(":");
let msgTypeRoom = data.startsWith("dm_message:") ? "dms" : "groups";
let msgDmId = parts[1];
let msgType = parts[2] || "unread";
if (msgType === "unread") {
updateDmListUI(msgDmId, false, Date.now().toString());
updateDmListUI(msgDmId, false, msgTypeRoom, Date.now().toString());
} else if (msgType === "read") {
updateDmListUI(msgDmId, true, Date.now().toString());
updateDmListUI(msgDmId, true, msgTypeRoom, Date.now().toString());
} else if (msgType === "update") {
// do nothing to UI sorting/read state
}
@ -3246,7 +3768,8 @@ async function togglePinMessage(msgId) {
string1: currentDmId,
string2: msgId
});
await fetchEncrypted("dm/message/pin/toggle", payload);
let endpoint = window.currentRoomType === 'groups' ? "group/message/pin/toggle" : "dm/message/pin/toggle";
await fetchEncrypted(endpoint, payload);
} catch (e) {
console.error(e);
showBlahNotification("error.network.error");
@ -3270,7 +3793,8 @@ async function deleteMessage(msgId) {
string1: currentDmId,
string2: msgId
};
let res = await fetchEncrypted("dm/message/delete", JSON.stringify(msgPayload));
let endpoint = window.currentRoomType === 'groups' ? "group/message/delete" : "dm/message/delete";
let res = await fetchEncrypted(endpoint, JSON.stringify(msgPayload));
if (res && res.startsWith("error:")) {
showBlahNotification(res);
}
@ -3346,8 +3870,15 @@ async function replyMessage(msgId) {
let content = msg ? msg.content : "";
if (msg && msg.key && msg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, msg.key);
if(k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
content = await decryptAesGcmFromBase64(content, dmKeyBytes);
}
} catch (e) {
}
}
@ -3454,8 +3985,15 @@ async function reactMessagePrompt(msgId, quickReaction = null) {
let rxVal = rx.r;
if (currentMsg.key && currentMsg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, currentMsg.key);
if(k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
rxVal = await decryptAesGcmFromBase64(rxVal, dmKeyBytes);
}
} catch(e) {}
}
if (rx.u === id && rxVal === reaction) {
@ -3468,10 +4006,17 @@ async function reactMessagePrompt(msgId, quickReaction = null) {
}
let isAdding = (myReactionId === null);
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, currentMsg.key);
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let encryptedReaction = await encryptAesGcmToBase64(reaction, dmKeyBytes);
let endpoint = isAdding ? "dm/message/react/add" : "dm/message/react/remove";
let endpoint = window.currentRoomType === 'groups'
? (isAdding ? "group/message/react/add" : "group/message/react/remove")
: (isAdding ? "dm/message/react/add" : "dm/message/react/remove");
let msgPayload = {
string1: currentDmId,
string2: msgId,
@ -3615,7 +4160,7 @@ async function changePassword() {
}
async function deleteAccount() {
if (!confirm(processBlah("desc.delete.account") + "\n\nAre you sure?")) return;
if (!confirm(processBlah("desc.delete.account") + "\n\n" + processBlah("prompt.are.you.sure"))) return;
showAction("action.account.deleting", "accountdel");
let res = await fetchPostEnc(`${url}/deleteaccount?id=${window.id}`, "");
clearAction("accountdel");
@ -3978,19 +4523,52 @@ if (container) {
document.addEventListener('touchend', endDrag);
}
function updateDmListUI(dmId, isRead, timestamp = null) {
function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
try {
if (!window.cachedDms) return;
let parsedDms = JSON.parse(window.cachedDms);
if (!parsedDms.dms || !parsedDms.dms[dmId]) return;
if (type === 'groups') {
if (!window.cachedGroups) return;
let parsedGroups = JSON.parse(window.cachedGroups);
if (!parsedGroups.groups || !parsedGroups.groups[roomId]) return;
let changed = false;
if (parsedDms.dms[dmId].string3 !== (isRead ? "true" : "false")) {
parsedDms.dms[dmId].string3 = isRead ? "true" : "false";
if (parsedGroups.groups[roomId].string3 !== (isRead ? "true" : "false")) {
parsedGroups.groups[roomId].string3 = isRead ? "true" : "false";
changed = true;
}
if (timestamp && parsedDms.dms[dmId].string2 !== timestamp) {
parsedDms.dms[dmId].string2 = timestamp;
if (timestamp && parsedGroups.groups[roomId].string2 !== timestamp) {
parsedGroups.groups[roomId].string2 = timestamp;
changed = true;
}
if (changed) {
window.cachedGroups = JSON.stringify(parsedGroups);
}
let btn = document.getElementById(`group-btn-${roomId}`);
let container = document.getElementById("groups-list");
if (btn && container) {
let nameSpan = btn.querySelector('.room-name');
if (nameSpan) {
nameSpan.style.opacity = isRead ? "0.8" : "1";
}
if (timestamp) {
container.prepend(btn);
}
} else {
if (typeof refreshGroups === 'function') refreshGroups(0, false);
}
} else {
if (!window.cachedDms) return;
let parsedDms = JSON.parse(window.cachedDms);
if (!parsedDms.dms || !parsedDms.dms[roomId]) return;
let changed = false;
if (parsedDms.dms[roomId].string3 !== (isRead ? "true" : "false")) {
parsedDms.dms[roomId].string3 = isRead ? "true" : "false";
changed = true;
}
if (timestamp && parsedDms.dms[roomId].string2 !== timestamp) {
parsedDms.dms[roomId].string2 = timestamp;
changed = true;
}
@ -3998,7 +4576,7 @@ function updateDmListUI(dmId, isRead, timestamp = null) {
window.cachedDms = JSON.stringify(parsedDms);
}
let btn = document.getElementById(`dm-btn-${dmId}`);
let btn = document.getElementById(`dm-btn-${roomId}`);
let container = document.getElementById("dms-list");
if (btn && container) {
let nameSpan = btn.querySelector('.room-name');
@ -4011,15 +4589,21 @@ function updateDmListUI(dmId, isRead, timestamp = null) {
} else {
if (typeof refreshDms === 'function') refreshDms(0, false);
}
}
} catch (e) {
console.error(e);
if (typeof refreshDms === 'function') refreshDms(0, false);
if (typeof refreshGroups === 'function') refreshGroups(0, false);
}
}
function markDmAsRead(dmId) {
fetchEncrypted("dm/read", JSON.stringify({string1: dmId})).catch(()=>{});
updateDmListUI(dmId, true);
function markDmAsRead(roomId, type = 'dms') {
if (type === 'groups') {
fetchEncrypted("group/read", JSON.stringify({string1: roomId})).catch(()=>{});
} else {
fetchEncrypted("dm/read", JSON.stringify({string1: roomId})).catch(()=>{});
}
updateDmListUI(roomId, true, type);
}
function handleDmInteraction() {
@ -4029,10 +4613,19 @@ function handleDmInteraction() {
if (lastChatScroll <= 10) {
try {
if (window.currentRoomType === 'groups') {
if (window.cachedGroups) {
let parsedGroups = JSON.parse(window.cachedGroups);
if (parsedGroups.groups && parsedGroups.groups[currentDmId] && parsedGroups.groups[currentDmId].string3 !== "true") {
markDmAsRead(currentDmId, window.currentRoomType);
}
}
} else {
if (window.cachedDms) {
let parsedDms = JSON.parse(window.cachedDms);
if (parsedDms.dms && parsedDms.dms[currentDmId] && parsedDms.dms[currentDmId].string3 !== "true") {
markDmAsRead(currentDmId);
markDmAsRead(currentDmId, window.currentRoomType);
}
}
}
} catch (e) {}
@ -4056,7 +4649,8 @@ async function showPinnedMessages(forceReload = false, showPanel = true) {
}
try {
let res = await fetchEncrypted("dm/messages/pinned", currentDmId);
let endpoint = window.currentRoomType === 'groups' ? "group/messages/pinned" : "dm/messages/pinned";
let res = await fetchEncrypted(endpoint, currentDmId);
if (res && !res.startsWith("error:")) {
let msgs = JSON.parse(res);
let entries = Object.entries(msgs).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));

View file

@ -117,10 +117,10 @@ var notifisScreen = `
var invitesEntry = `
<div class="invite-entry">
<div class="invite-info">
<img src="{pfp}" class="invite-pfp" onclick="openProfile('{id}')" style="cursor: pointer;">
<img src="{pfp}" class="invite-pfp" {onclick_attr}>
<div class="invite-details">
<span class="invite-name" onclick="openProfile('{id}')" style="cursor: pointer;">{username}</span>
<span class="invite-desc"><blah>{desc}</blah></span>
<span class="invite-name" {onclick_attr}>{username}</span>
<span class="invite-desc">{desc}</span>
</div>
</div>
<div class="invite-actions">

View file

@ -215,6 +215,25 @@ sidebar {
width: calc(var(--icon-button-height) + (var(--button-margin) * 2) + var(--border-width));
border-right: var(--border-width) solid var(--light-border-color);
}
sidebar:not(.second) {
overflow-y: auto;
overflow-x: hidden;
}
sidebar:not(.second)::-webkit-scrollbar {
display: none;
}
sidebar:not(.second) {
-ms-overflow-style: none;
scrollbar-width: none;
}
#roomsbar roomcontent2 {
overflow-y: auto;
overflow-x: hidden;
}
sidebar.second {
width: 17rem;
min-width: 17rem;

View file

@ -10,6 +10,8 @@
"dm.accepted": "direct meowchat accepted",
"no.invite.found": "you can't create a dm without invitation *hiss*",
"unknown.error": "unknown error :o",
"missing.keys": "cannot rotate nametags: some kitties don't have public nametags available yet. they must set up their identity.",
"key.rotated": "meowchat keys have been successfully rotated! meow!",
"forbidden": "you do not have pawmission to perform this action *hiss*",
"dm.key.too.large": "meowchat key too large :o",
"dm.key.not.found": "meowchat key not found :c",
@ -125,6 +127,7 @@
"desc.messages.loading": "loading meows...",
"desc.no.dms": "no direct meowchats :c",
"action.dm.opening": "opening meowchat...",
"action.group.opening": "opening clowder...",
"dm.open.failed": "failed to open meowchat :c",
"keys.local.server.mismatch": "this device has different nametags than server :c use the device u first logged in on",
"keys.server.decrypt.failed": "wrong password for ur secret nametag bundle :c",
@ -201,5 +204,58 @@
"message.unpinned.by": "{0} unpinned a {1}.",
"title.pinned.messages": "Pinned",
"error.pinned.messages.empty": "No pinned meows",
"title.message.lowercase": "meowsage"
"title.message.lowercase": "meowsage",
"action.rotate.keys": "Meowtate Keys",
"already.in.group": "Meowready in group",
"desc.key.rotation.info": "Meow need to purrotate meowcription keys to purrtinue sending meowsages in this group. Other meowmbers will autopurrtically receive the new key.",
"desc.no.groups": "Meow are not in any groups yet.",
"dm.read": "Purread",
"empty.body": "Empty meowdy",
"failed.create.group": "Failed to purreate group",
"federation.not.implemented": "Meowderation not implemeowted",
"group.not.found": "Group not meownd",
"group.read": "Purread",
"invalid.entry.name": "Invalid purrtry name",
"invalid.key.index": "Invalid key index (purrlease purrefresh the page)",
"invalid.msg.id": "Invalid meowsage ID",
"invalid.payload": "Invalid purrload",
"invalid.reaction.id": "Invalid purreaction ID",
"invalid.user": "Invalid meowser",
"joined": "Purroined",
"key.rotation.required": "Key purrotation meowquired",
"key.too.large": "Key too meowrge",
"keys.public.mismatch": "Public keys mismemeow",
"label.members": "MEOWMBERS",
"message.deleted": "Meowsage purrleted",
"message.not.found": "Meowsage not meownd",
"message.pin.toggled": "Meowsage purrin purrggled",
"message.reacted": "Meowsage purreacted",
"message.reaction.removed": "Purreaction remeowed",
"no.invite": "No meowvite",
"not.a.member": "Not a meowmber",
"not.in.group": "Not in purroup",
"reaction.not.found": "Purreaction not meownd",
"rotation.not.required": "Purrotation not meowquired",
"storage.entry.updated": "Storage purrtry updated",
"storage.limit.exceeded": "Storage limit exmeowded",
"title.key.rotation": "Key Purrotation",
"error:messages.decrypt.failed": "Depurryption meowled",
"error.message.already.deleted": "Meowsage alpurrdy meowleted",
"group.user.joined.by": "{0} purroined the group",
"group.created.by": "Group purreated by {0}",
"group.user.left.by": "{0} left the purroup",
"action.group.fetch": "Meowtching groups",
"group.created": "Group purreated",
"group.user.joined": "Purroined group",
"prompt.invite.username": "Enter username to invite meow:",
"desc.failed.load.members": "Failed to load members meow",
"prompt.are.you.sure": "Are you sure meow?",
"title.unknown.group": "Unknown Group meow",
"title.unknown": "Unknown meow",
"en-us": "English meow",
"pl-pl": "Polish meow",
"en-cat": "Meowlish",
"avatar.updated": "Avatar updated successfully",
"profile.updated": "Profile updated successfully",
"banner.updated": "Banner updated successfully"
}

View file

@ -10,6 +10,8 @@
"dm.accepted": "DM accepted",
"no.invite.found": "You can't create a dm without invitation",
"unknown.error": "Unknown error",
"missing.keys": "Cannot rotate keys: Some members don't have public keys available yet. They must configure their identity.",
"key.rotated": "Group keys have been successfully rotated.",
"forbidden": "You do not have permission to perform this action",
"dm.key.too.large": "DM key too large",
"dm.key.not.found": "DM key not found",
@ -124,6 +126,7 @@
"desc.messages.loading": "Fetching messages...",
"desc.no.dms": "No direct messages",
"action.dm.opening": "Opening DM...",
"action.group.opening": "Opening group...",
"dm.open.failed": "Failed to open DM",
"keys.local.server.mismatch": "This device has different encryption keys than the server. Use the device where you first logged in, or restore server keys from backup.",
"keys.server.decrypt.failed": "Could not decrypt your account keys with this password.",
@ -201,5 +204,58 @@
"message.unpinned.by": "{0} unpinned a {1}.",
"title.pinned.messages": "Pinned",
"error.pinned.messages.empty": "No pinned messages",
"title.message.lowercase": "message"
"title.message.lowercase": "message",
"action.rotate.keys": "Rotate Keys",
"already.in.group": "Already in group",
"desc.key.rotation.info": "You need to rotate encryption keys to continue sending messages in this group. Other members will automatically receive the new key.",
"desc.no.groups": "You are not in any groups yet.",
"dm.read": "Read",
"empty.body": "Empty body",
"failed.create.group": "Failed to create group",
"federation.not.implemented": "Federation not implemented",
"group.not.found": "Group not found",
"group.read": "Read",
"invalid.entry.name": "Invalid entry name",
"invalid.key.index": "Invalid key index (please refresh the page)",
"invalid.msg.id": "Invalid message ID",
"invalid.payload": "Invalid payload",
"invalid.reaction.id": "Invalid reaction ID",
"invalid.user": "Invalid user",
"joined": "Joined",
"key.rotation.required": "Key rotation required",
"key.too.large": "Key too large",
"keys.public.mismatch": "Public keys mismatch",
"label.members": "MEMBERS",
"message.deleted": "Message deleted",
"message.not.found": "Message not found",
"message.pin.toggled": "Message pin toggled",
"message.reacted": "Message reacted",
"message.reaction.removed": "Reaction removed",
"no.invite": "No invite",
"not.a.member": "Not a member",
"not.in.group": "Not in group",
"reaction.not.found": "Reaction not found",
"rotation.not.required": "Rotation not required",
"storage.entry.updated": "Storage entry updated",
"storage.limit.exceeded": "Storage limit exceeded",
"title.key.rotation": "Key Rotation",
"error:messages.decrypt.failed": "Decryption failed",
"error.message.already.deleted": "Message already deleted",
"group.user.joined.by": "{0} joined the group",
"group.created.by": "Group created by {0}",
"group.user.left.by": "{0} left the group",
"action.group.fetch": "Fetching groups",
"group.created": "Group created",
"group.user.joined": "Joined group",
"prompt.invite.username": "Enter username to invite:",
"desc.failed.load.members": "Failed to load members",
"prompt.are.you.sure": "Are you sure?",
"title.unknown.group": "Unknown Group",
"title.unknown": "Unknown",
"success:banner.updated": "Banner updated successfully",
"success:profile.updated": "Profile updated successfully",
"success:avatar.updated": "Avatar updated successfully",
"en-us": "English",
"pl-pl": "Polish",
"en-cat": "Meowlish"
}

View file

@ -124,6 +124,7 @@
"desc.messages.loading": "Pobieranie wiadomości...",
"desc.no.dms": "Brak wiadomości prywatnych",
"action.dm.opening": "Otwieranie rozmowy...",
"action.group.opening": "Otwieranie grupy...",
"dm.open.failed": "Nie udało się otworzyć rozmowy",
"keys.local.server.mismatch": "To urządzenie ma inne klucze szyfrowania niż serwer. Użyj urządzenia, na którym zalogowano się po raz pierwszy, lub przywróć klucze z kopii zapasowej.",
"keys.server.decrypt.failed": "Nie udało się odszyfrować kluczy konta przy użyciu tego hasła.",
@ -201,5 +202,60 @@
"message.unpinned.by": "{0} odpiął {1}.",
"title.pinned.messages": "Przypięte",
"error.pinned.messages.empty": "Brak przypiętych wiadomości",
"title.message.lowercase": "wiadomość"
"title.message.lowercase": "wiadomość",
"action.rotate.keys": "Rotuj Klucze",
"already.in.group": "Już w grupie",
"desc.key.rotation.info": "Musisz zrotować klucze szyfrujące, aby kontynuować wysyłanie wiadomości w tej grupie. Inni członkowie automatycznie otrzymają nowy klucz.",
"desc.no.groups": "Nie jesteś jeszcze w żadnej grupie.",
"dm.read": "Przeczytano",
"empty.body": "Pusta zawartość",
"failed.create.group": "Nie udało się utworzyć grupy",
"federation.not.implemented": "Federacja niezaimplementowana",
"group.not.found": "Nie znaleziono grupy",
"group.read": "Przeczytano",
"invalid.entry.name": "Nieprawidłowa nazwa wpisu",
"invalid.key.index": "Nieprawidłowy indeks klucza (odśwież stronę)",
"invalid.msg.id": "Nieprawidłowe ID wiadomości",
"invalid.payload": "Nieprawidłowy ładunek",
"invalid.reaction.id": "Nieprawidłowe ID reakcji",
"invalid.user": "Nieprawidłowy użytkownik",
"joined": "Dołączono",
"key.rotation.required": "Wymagana rotacja kluczy",
"key.too.large": "Klucz zbyt duży",
"keys.public.mismatch": "Niezgodność kluczy publicznych",
"label.members": "CZŁONKOWIE",
"message.deleted": "Wiadomość usunięta",
"message.not.found": "Nie znaleziono wiadomości",
"message.pin.toggled": "Zmieniono przypięcie wiadomości",
"message.reacted": "Dodano reakcję",
"message.reaction.removed": "Reakcja usunięta",
"no.invite": "Brak zaproszenia",
"not.a.member": "Nie jesteś członkiem",
"not.in.group": "Nie jesteś w grupie",
"reaction.not.found": "Nie znaleziono reakcji",
"rotation.not.required": "Rotacja niewymagana",
"storage.entry.updated": "Zaktualizowano wpis pamięci",
"storage.limit.exceeded": "Przekroczono limit pamięci",
"title.key.rotation": "Rotacja kluczy",
"missing.keys": "Brakujące klucze",
"error:messages.decrypt.failed": "Deszyfrowanie nie powiodło się",
"error.message.already.deleted": "Wiadomość została już usunięta",
"group.user.joined.by": "{0} dołączył do grupy",
"group.created.by": "Grupa utworzona przez {0}",
"group.user.left.by": "{0} opuścił grupę",
"action.group.fetch": "Pobieranie grup",
"group.created": "Utworzono grupę",
"group.user.joined": "Dołączono do grupy",
"prompt.invite.username": "Podaj nazwę użytkownika, aby zaprosić:",
"desc.failed.load.members": "Nie udało się załadować członków",
"prompt.are.you.sure": "Czy na pewno?",
"title.unknown.group": "Nieznana grupa",
"title.unknown": "Nieznany",
"key.rotated": "Klucze grupy zostały pomyślnie zrotowane.",
"success:banner.updated": "Baner został pomyślnie zaktualizowany",
"success:profile.updated": "Profil został pomyślnie zaktualizowany",
"success:avatar.updated": "Awatar został pomyślnie zaktualizowany",
"en-us": "Angielski",
"pl-pl": "Polski",
"en-cat": "Koci (Meowlish)"
}

View file

@ -119,6 +119,25 @@
</div>
</div>
</div>
<div id="rotation-popup-container" class="profile-popup-container" style="display: none; z-index: 9999;">
<div class="profile-popup-bg" onclick="closeRotationPopup()"></div>
<div class="profile-popup-content">
<button class="profile-popup-close-btn" onclick="closeRotationPopup()">
<script>
document.currentScript.insertAdjacentHTML('beforebegin', ICONS.close());
document.currentScript.remove();
</script>
</button>
<div id="profile-popup-inner" style="padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem;">
<div style="font-size: 1.2rem; font-weight: 500; text-align: center;"><blah>title.key.rotation</blah></div>
<div style="width: 100%; text-align: center; color: var(--text-color); opacity: 0.8; margin-top: 1rem; margin-bottom: 1rem;" id="rotation-popup-desc">
<!-- content injected dynamically -->
</div>
<button class="profile-action-btn" onclick="executeKeyRotation()"><blah>action.rotate.keys</blah></button>
</div>
</div>
</div>
</body>
</html>
@ -208,8 +227,59 @@
}
}
async function createGroup() {
async function refreshGroups(waittime = 0, showLoading = true) {
try {
if (showLoading) showAction("action.group.fetch", "grouprefresh");
await delay(waittime);
if (window.cachedGroups && typeof renderGroups === 'function') {
await renderGroups(window.cachedGroups);
}
let res = await fetchEncrypted("user/group/list");
window.cachedGroups = res;
if (typeof renderGroups === 'function') {
await renderGroups(res);
}
if (showLoading) clearAction("grouprefresh");
}
catch (e) {
if (showLoading) clearAction("grouprefresh");
showBlahNotification("error:group.refresh.failed");
}
}
async function createGroup() {
try {
let groupName = document.getElementById("creategroup-name").value;
if (!groupName || groupName.trim() === "") {
showBlahNotification("error:group.name.empty");
return;
}
showAction("action.group.creating", "group.action");
await ensureUserKeys();
const roomKey = randomRoomKey256Chars();
const selfWrapped = await wrapRoomKeyForSelf(roomKey);
let payload = JSON.stringify({
string1: groupName,
string2: selfWrapped
});
let res = await fetchEncrypted("group/create", payload);
clearAction("group.action");
if (res.startsWith("success:")) {
showBlahNotification("success:group.created");
await refreshGroups(200, false);
} else {
showBlahNotification(res || "error:something.wrong");
}
} catch (e) {
clearAction("group.action");
showBlahNotification("error:something.wrong");
console.error(e);
}
}
</script>
<script src="gifuct.js"></script>

View file

@ -367,6 +367,171 @@ async function ensureDmRoomKey(dmId) {
window.ensureDmRoomKey = ensureDmRoomKey;
window.groupKeysCache = {};
async function getGroupKey(groupId, keyIndex) {
if (!window.groupKeysCache[groupId]) {
window.groupKeysCache[groupId] = {};
}
if (window.groupKeysCache[groupId][keyIndex]) {
return window.groupKeysCache[groupId][keyIndex];
}
let payload = JSON.stringify({string1: groupId, string2: keyIndex.toString()});
let res = await fetchEncrypted("group/key/get", payload);
if (!res || res.startsWith("error:")) {
return null;
}
try {
let decryptedKey;
if (res.startsWith("SETUP:")) {
const rest = res.substring("SETUP:".length);
const parts = rest.split(";");
if (parts.length < 2) throw new Error("error:group.key.setup.malformed");
const otherPublicJson = parts[0];
const setupPayloadJson = parts.slice(1).join(";");
const otherPublic = JSON.parse(otherPublicJson);
const setupPayload = JSON.parse(setupPayloadJson);
const aesKeyBytes = await deriveHybridKeyFromSetup(otherPublic, setupPayload.ctMlKem);
decryptedKey = await decryptAesGcmFromBase64(setupPayload.enc, aesKeyBytes);
} else {
decryptedKey = await unwrapRoomKeyForSelf(res);
}
window.groupKeysCache[groupId][keyIndex] = decryptedKey;
return decryptedKey;
} catch(e) {
return null;
}
}
function closeRotationPopup() {
let popupContainer = document.getElementById("rotation-popup-container");
if(popupContainer) {
popupContainer.classList.remove("show");
setTimeout(() => {
popupContainer.style.display = "none";
}, 200);
}
clearAction("key_rotation");
}
let pendingRotationMessagePayload = null;
async function showRotationPopup(groupId, originalMessagePayload) {
pendingRotationMessagePayload = originalMessagePayload;
showAction("action.key.rotation", "key_rotation");
try {
let membersRaw = await fetchEncrypted("group/members", groupId);
if(membersRaw && membersRaw.startsWith("error:")) throw new Error(membersRaw);
let members = JSON.parse(membersRaw);
let desc = document.getElementById("rotation-popup-desc");
desc.innerHTML = `<blah>desc.key.rotation.info</blah><br><br><b>${members.length} <blah>label.members</blah></b>`;
let blahTags = desc.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
let popupContainer = document.getElementById("rotation-popup-container");
popupContainer.style.display = "flex";
popupContainer.offsetHeight; // trigger reflow
popupContainer.classList.add("show");
} catch(e) {
clearAction("key_rotation");
showBlahNotification("error:something.wrong");
}
}
async function executeKeyRotation() {
closeRotationPopup();
showAction("action.key.rotating", "key_rotation_exec");
try {
let groupId = currentDmId;
let membersRaw = await fetchEncrypted("group/members", groupId);
if(membersRaw && membersRaw.startsWith("error:")) throw new Error(membersRaw);
let members = JSON.parse(membersRaw);
let roomKey = randomRoomKey256Chars();
let keysDict = {};
let myPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${window.id}`);
for(let memberId of members) {
let pureId = memberId.split(";")[0];
if(pureId === window.id) {
keysDict[memberId] = await wrapRoomKeyForSelf(roomKey);
} else {
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${pureId}`);
if(publicKeysRaw && publicKeysRaw.length > 10) {
let publicKeys = JSON.parse(publicKeysRaw);
let hybrid = await deriveHybridKeyToUser(publicKeys);
let encForMember = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
let setupPayloadJson = JSON.stringify({
v: 1,
alg: "hybrid-x25519-mlkem768",
ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForMember
});
keysDict[memberId] = `SETUP:${myPublicKeysRaw};${setupPayloadJson}`;
} else {
throw new Error("error:missing.keys");
}
}
}
let payload = JSON.stringify({
groupId: groupId,
keys: keysDict
});
let res = await fetchEncrypted("group/key/rotate", payload);
if(res && res.startsWith("error:")) {
throw new Error(res);
}
// Success!
let newIndex = res.replace("success:", "");
window.currentGroupKeyIndex = newIndex;
if(!window.groupKeysCache[groupId]) window.groupKeysCache[groupId] = {};
window.groupKeysCache[groupId][newIndex] = roomKey;
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");
}
}
async function encrypt(plainText, keyBytes) {
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const encoder = new TextEncoder();
@ -857,6 +1022,18 @@ async function getAvatarUrl(id, username) {
}
}
async function getGroupAvatarUrl(groupId, groupName) {
try {
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
if ((await fetchAsync(pfpUrl)) == "") {
throw Error();
}
return pfpUrl;
} catch (e) {
return createAvatarSvg(groupName);
}
}
function updateLoadingStatus(message) {
loadingStatus.innerHTML = processBlah(message);
}
@ -1743,6 +1920,71 @@ function setActiveRoombarItem(itemId) {
}
}
async function acceptGroupInvite(groupId, inviterId) {
try {
showAction("action.invite.accepting", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: inviterId
});
let res = await fetchEncrypted("group/join", payload);
clearAction("invite.action");
showBlahNotification(res || "success:joined");
// Refresh invites and groups
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("received");
if (typeof refreshGroups === 'function') refreshGroups(0, false);
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function declineGroupInvite(groupId, inviterId) {
try {
showAction("action.invite.declining", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: inviterId
});
let res = await fetchEncrypted("group/invite/decline", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.declined");
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("received");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function revokeGroupInvite(groupId, targetId) {
try {
showAction("action.invite.revoking", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: targetId
});
let res = await fetchEncrypted("group/invite/revoke", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.revoked");
window.cachedInvites = {'received': null, 'sent': null};
loadInvites("sent");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function renderInvites(res, type) {
let container = document.getElementById("invites-container");
if (!container) return;
@ -1781,7 +2023,7 @@ async function renderInvites(res, type) {
}
let onlyId = id.split(":")[0];
let username = await fetchAsync(`${url}/idtoname?id=${id}`);
if (!(username && username.trim() !== "")) return;
if (!(username && username.trim() !== "")) continue;
count++;
let pfp = await getAvatarUrl(id, username);
@ -1789,8 +2031,9 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
let displayName = username.split(':')[0] || onlyId;
if (type === "received") {
desc = `:desc.invite.dm.received:${username};${onlyId}`;
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = `
<button class="icon-button" style="background-color: var(--big-green);" onclick="acceptInvite('${id}')">
${ICONS.check({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
@ -1800,7 +2043,7 @@ async function renderInvites(res, type) {
</button>
`;
} else {
desc = `:desc.invite.dm.sent:${username};${onlyId}`;
desc = `:desc.invite.dm.sent:${displayName};${onlyId}`;
actions = `
<button class="icon-button" style="background-color: var(--big-red);" onclick="revokeInvite('${id}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
@ -1809,12 +2052,70 @@ async function renderInvites(res, type) {
}
let entry = invitesEntry
.replaceAll("{username}", username.split(':')[0])
.replaceAll("{username}", displayName)
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", desc)
.replaceAll("{desc}", `<blah>${desc}</blah>`)
.replaceAll("{actions}", actions)
.replaceAll("{onclick_attr}", `onclick="openProfile('${id}')" style="cursor: pointer;"`)
.replaceAll("{id}", id);
html += entry;
} else if (invite.type === "group") {
let groupId = invite.id;
let targetId = (type === "received") ? invite.sender : invite.receiver;
if (!targetId.includes(":")) {
targetId += `:${host}`;
}
let onlyId = targetId.split(":")[0];
let username = await fetchAsync(`${url}/idtoname?id=${targetId}`);
if (!(username && username.trim() !== "")) continue;
// Fetch group name
let groupInfoStr = await fetchEncrypted("group/info", groupId);
let groupName = processBlah("title.unknown.group");
if (groupInfoStr && !groupInfoStr.startsWith("error")) {
let groupInfo = JSON.parse(groupInfoStr);
groupName = groupInfo.string2;
}
count++;
let pfp = await getGroupAvatarUrl(groupId, groupName);
let actions = "";
let desc = "";
let displayName = username.split(':')[0] || onlyId;
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>`;
if (type === "received") {
desc = `:desc.invite.group.received:${val1};${val2}`;
actions = `
<button class="icon-button" style="background-color: var(--big-green);" onclick="acceptGroupInvite('${groupId}', '${targetId}')">
${ICONS.check({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
<button class="icon-button" style="background-color: var(--big-red);" onclick="declineGroupInvite('${groupId}', '${targetId}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
`;
} else {
desc = `:desc.invite.group.sent:${val1};${val2}`;
actions = `
<button class="icon-button" style="background-color: var(--big-red);" onclick="revokeGroupInvite('${groupId}', '${targetId}')">
${ICONS.close({ fill: "#fff", width: "1.5rem", height: "1.5rem" })}
</button>
`;
}
let entry = invitesEntry
.replaceAll("{username}", groupName)
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", `<blah>${desc}</blah>`)
.replaceAll("{actions}", actions)
.replaceAll("{onclick_attr}", "")
.replaceAll("{id}", groupId);
html += entry;
}
}
@ -1996,7 +2297,10 @@ async function gotoHome(splash = true) {
switchRoomsBar("title.home", homeRoomBar);
setActiveRoombarItem(null);
setupWebSocket();
await refreshDms(210);
await Promise.all([
refreshDms(210),
(typeof refreshGroups === 'function' ? refreshGroups(210) : Promise.resolve())
]);
}
function setActiveSidebarIndicator(element, isTemporary = false) {
@ -2210,6 +2514,67 @@ async function renderDms(res) {
}
}
async function renderGroups(res) {
let container = document.getElementById("groups-list");
if (!container) return;
if (res && res !== "") {
let parsed = JSON.parse(res);
let groups = parsed.groups || {};
let groupEntries = Object.entries(groups);
//Sort by timestamp descending
groupEntries.sort((a, b) => (parseInt(b[1].string2) || 0) - (parseInt(a[1].string2) || 0));
let html = "";
let count = 0;
for (let [groupId, groupData] of groupEntries) {
let isRead = groupData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && infoRes.startsWith("{")) {
let info = JSON.parse(infoRes);
let groupName = info.string2;
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName);
html += `
<button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')">
<img src="${pfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<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>
</div>
</button>
`;
count++;
}
}
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
} else {
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
}
} else {
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
blahTags[i].innerHTML = processBlah(blahTags[i].innerHTML);
}
}
}
var currentDmId = null;
var currentDmKey = null; // AES key for encrypting/decrypting messages
var dmUsernameCache = {};
@ -2263,30 +2628,131 @@ async function generateDmMembersHtml(targetId, targetUsername) {
`;
}
async function openDm(dmId, username, targetId) {
async function inviteToGroupPrompt(groupId) {
let targetUsername = prompt(processBlah("prompt.invite.username"));
if (!targetUsername) return;
if (!targetUsername.includes(":")) {
targetUsername += `:${host}`;
}
let idRes = await fetchAsync(`${url}/nametoid?u=${targetUsername}`);
if (idRes.startsWith("error") || idRes.trim() === "0" || idRes.trim() === "") {
showBlahNotification("error:user.not.found");
return;
}
let targetId = idRes.trim();
try {
showAction("action.invite.sending", "invite.action");
let payload = JSON.stringify({
string1: groupId,
string2: targetId
});
let res = await fetchEncrypted("group/invite", payload);
clearAction("invite.action");
showBlahNotification(res || "success:invite.sent");
} catch (e) {
clearAction("invite.action");
showBlahNotification("error:something.wrong");
}
}
async function generateGroupMembersHtml(groupId, groupName) {
let membersJson = await fetchEncrypted("group/members", groupId);
if (!membersJson || membersJson.startsWith("error")) {
return `<div style="text-align: center; opacity: 0.5; padding: 1rem;"><blah>desc.failed.load.members</blah></div>`;
}
let members = JSON.parse(membersJson);
let html = `
<div class="list-container no-flex-grow" style="padding: 0 0.5rem; width: 100%;">
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%; display: flex; align-items: center;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
<div class="flex-spacer"></div>
<button class="topbar-action-btn" onclick="inviteToGroupPrompt('${groupId}')" style="margin-right: 0.5rem; width: 1.2rem; height: 1.2rem; display: flex; align-items: center; justify-content: center; opacity: 0.7;">
${ICONS.plus({ width: "1rem", height: "1rem" })}
</button>
</div>`;
for (let memberId of members) {
let memberUsername = await fetchAsync(`${url}/idtoname?id=${memberId}`);
if (!memberUsername || memberUsername.startsWith("error")) continue;
let memberPfp = await getAvatarUrl(memberId, memberUsername);
let memberDisplayName = memberUsername.split(':')[0];
html += `
<button class="room-entry" onclick="openProfile('${memberId}')" style="margin-bottom: 0;">
<img src="${memberPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${memberDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${memberUsername}</span>
</div>
</button>`;
}
html += `</div>`;
return html;
}
async function openDm(dmId, username, targetId, type = 'dms') {
try {
showAction("action.dm.opening", "dmopen");
markDmAsRead(dmId);
if (type === 'groups') {
markDmAsRead(dmId, 'groups');
} else {
markDmAsRead(dmId, 'dms');
}
if (type === 'dms') {
currentDmKey = await ensureDmRoomKey(dmId);
if (!currentDmKey) {
throw new Error("Missing DM room key");
}
} else {
let infoPayload = await fetchEncrypted("group/info", dmId);
if (infoPayload && !infoPayload.startsWith("error:")) {
let info = JSON.parse(infoPayload);
window.currentGroupKeyIndex = info.string4 || "0";
} else {
window.currentGroupKeyIndex = "0";
}
}
if (currentDmId !== dmId) {
currentRoomContentTitle = "";
}
currentDmId = dmId;
window.currentRoomType = type; // Save the type globally
loadedMessages = {};
oldestLoadedMsgId = null;
isLoadingOlderMessages = false;
let iconHtml = "";
if (type === 'dms') {
let pfp = await getAvatarUrl(targetId, username);
let 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; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
} else {
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);">`;
}
if (currentRoomsBarTitle !== "title.home") {
gotoHome(false);
}
await switchRoomContent(username.split(':')[0], chatScreen, true, iconHtml);
let membersHtml = await generateDmMembersHtml(targetId, username);
let membersHtml = "";
if (type === 'dms') {
membersHtml = await generateDmMembersHtml(targetId, username);
} else {
membersHtml = await generateGroupMembersHtml(dmId, username);
}
await switchDetailsContent("title.details", membersHtml);
let msgContainer = document.getElementById("chat-messages");
@ -2326,7 +2792,8 @@ async function openDm(dmId, username, targetId) {
async function loadDmMessages(dmId, startOffset = "", isPrepend = false) {
try {
let payload = JSON.stringify({string1: dmId, string2: "50", string3: startOffset});
let res = await fetchEncrypted("dm/messages/get", payload);
let endpoint = window.currentRoomType === 'groups' ? "group/messages/get" : "dm/messages/get";
let res = await fetchEncrypted(endpoint, payload);
if (res && res.startsWith("error:")) {
showBlahNotification(res);
@ -2389,9 +2856,26 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let decrypted = false;
if (msg.key && msg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
content = await decryptAesGcmFromBase64(content, dmKeyBytes);
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
keyToUse = await getGroupKey(currentDmId, msg.key);
if (keyToUse) {
let mK = parseInt(msg.key);
let cK = parseInt(window.currentGroupKeyIndex || "0");
if (!isNaN(mK) && !isNaN(cK) && mK > cK) {
window.currentGroupKeyIndex = msg.key;
}
}
}
if (!keyToUse) {
content = `<span style="color:var(--big-red)"><blah>error:messages.decrypt.failed</blah></span>`;
decrypted = false;
} else {
let keyBytes = await sha256Bytes(base64ToUint8(keyToUse));
content = await decryptAesGcmFromBase64(content, keyBytes);
decrypted = true;
}
} catch (e) {
content = `<span style="color:var(--big-red)"><blah>error:messages.decrypt.failed</blah></span>`;
decrypted = false;
@ -2423,7 +2907,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let safeType = encodeURIComponent(att.type).replace(/'/g, "%27");
let safeName = encodeURIComponent(att.name).replace(/'/g, "%27");
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'))">
attachmentsHtml += `<div id="${attContainerId}" class="attachment-box" style="position: relative; min-width: 120px; max-width: 300px; min-height: 100px; max-height: 300px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden;" onclick="event.stopPropagation(); downloadAndRenderAttachment(this, '${msgId}', '${att.id}', decodeURIComponent('${safeType}'), decodeURIComponent('${safeName}'), '${msg.key || "0"}')">
<div style="padding: 1rem; text-align: center; color: var(--text-color);">
${iconSvg}
<div style="font-size: 0.8rem; margin-top: 0.5rem; word-break: break-all;">${escapeHtml(displayName)}</div>
@ -2454,8 +2938,8 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
content = processBlah(content);
if (msg.type && msg.type.startsWith("larp.info")) {
if (msg.type === "larp.info.pin" || msg.type === "larp.info.unpin") {
let authorName = "Unknown";
if (msg.type === "larp.info.pin" || msg.type === "larp.info.unpin" || msg.type === "larp.info.join" || msg.type === "larp.info.leave" || msg.type === "larp.info.create") {
let authorName = processBlah("title.unknown");
if (msg.author && msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
let fullUsername = await fetchAsync(`${url}/idtoname?id=${msg.author}`);
@ -2471,14 +2955,22 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
}
}
let isPinned = msg.type === "larp.info.pin";
let actionStr = isPinned ? "message.pinned.by" : "message.unpinned.by";
let actionStr = "";
if (msg.type === "larp.info.pin") actionStr = "message.pinned.by";
else if (msg.type === "larp.info.unpin") actionStr = "message.unpinned.by";
else if (msg.type === "larp.info.join") actionStr = "group.user.joined.by";
else if (msg.type === "larp.info.leave") actionStr = "group.user.left.by";
else if (msg.type === "larp.info.create") actionStr = "group.created.by";
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${msg.pervious}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
let textStr = processBlah(actionStr);
if (msg.type.endsWith(".pin") || msg.type.endsWith(".unpin")) {
let clickableMsg = `<span onclick="event.stopPropagation(); scrollToPinnedMessage(event, '${msg.pervious}')" style="cursor: pointer; font-weight: bold; color: rgba(255, 255, 255, 0.9);">${processBlah('title.message.lowercase')}</span>`;
content = textStr.replace("{0}", clickableAuthor).replace("{1}", clickableMsg);
} else {
content = textStr.replace("{0}", clickableAuthor);
}
}
html += `<div style="text-align: center; opacity: 0.5; padding: 1rem; font-size: 0.9rem; word-break: break-word; overflow-wrap: anywhere;">${content}</div>`;
lastAuthor = null;
@ -2501,7 +2993,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
}
lastAuthor = msg.author;
let authorName = "Unknown";
let authorName = processBlah("title.unknown");
let pfp = "assets/default_avatar.png";
if (msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
@ -2618,8 +3110,15 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
let rxVal = rx.r;
if (msg.key && msg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, msg.key);
if (k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
rxVal = await decryptAesGcmFromBase64(rxVal, dmKeyBytes);
}
} catch(e) {}
}
if (!rxCounts[rxVal]) rxCounts[rxVal] = {count: 0, me: false, myReactionId: null};
@ -2839,8 +3338,8 @@ function setupChatScrollListener() {
async function downloadAndRenderAttachment(container, msgId, attId, type, name) {
if (!currentDmId || !currentDmKey) return;
async function downloadAndRenderAttachment(container, msgId, attId, type, name, keyIndex = "0") {
if (!currentDmId || (window.currentRoomType === 'dms' && !currentDmKey)) return;
if (!container) return;
if (container.dataset.loaded === "true") {
@ -2860,10 +3359,16 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name)
container.innerHTML = `<div style="padding: 1rem; color: var(--text-color);">${ICONS.spinner()}</div>`;
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, keyIndex);
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let nonce = await getNonce(id, passwordHash);
let res = await fetch(`${url}/dm/attachment/get?id=${id}&dm=${currentDmId}&msg=${msgId}&att=${attId}`, {
let endpointType = window.currentRoomType === 'groups' ? "group" : "dm";
let res = await fetch(`${url}/${endpointType}/attachment/get?id=${id}&dm=${currentDmId}&msg=${msgId}&att=${attId}`, {
headers: {
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
}
@ -2953,7 +3458,7 @@ function renderAttachmentsPreview() {
}
async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) {
if (!currentDmId || !currentDmKey) return;
if (!currentDmId || (window.currentRoomType === 'dms' && !currentDmKey)) return;
let input = document.getElementById("chat-input");
let content = input.value.trim();
@ -2975,7 +3480,13 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
}
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, window.currentGroupKeyIndex || "0");
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes);
let attachmentsPayload = [];
@ -3036,6 +3547,11 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
string4: replyingToMsgId || ""
};
if (window.currentRoomType === 'groups') {
msgPayload.string5 = window.currentGroupKeyIndex || "0";
msgPayload.rawPlainContent = content; // for retry
}
input.value = "";
input.style.height = '2.5rem';
sendbutton.style.height = '2.5rem';
@ -3049,9 +3565,14 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
showAction("action.message.sending", "msgsending");
let res = await fetchEncrypted("dm/message/send", JSON.stringify(msgPayload));
let res = await fetchEncrypted(window.currentRoomType === 'groups' ? "group/message/send" : "dm/message/send", JSON.stringify(msgPayload));
clearAction("msgsending");
if (res && res.startsWith("error:")) {
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
showRotationPopup(currentDmId, msgPayload);
} else if (res && res.startsWith("error:")) {
showBlahNotification(res);
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
@ -3073,7 +3594,7 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
type: "larp.text",
content: encryptedContent,
attachment: finalAttPayload,
key: "0",
key: window.currentRoomType === 'groups' ? (window.currentGroupKeyIndex || "0") : "0",
pervious: "",
responded: replyingToMsgId || "",
reactions: ""
@ -3148,15 +3669,16 @@ function setupWebSocket() {
appWebSocket.onmessage = (event) => {
let data = event.data;
if (data.startsWith("dm_message:")) {
if (data.startsWith("dm_message:") || data.startsWith("group_message:")) {
let parts = data.split(":");
let msgTypeRoom = data.startsWith("dm_message:") ? "dms" : "groups";
let msgDmId = parts[1];
let msgType = parts[2] || "unread";
if (msgType === "unread") {
updateDmListUI(msgDmId, false, Date.now().toString());
updateDmListUI(msgDmId, false, msgTypeRoom, Date.now().toString());
} else if (msgType === "read") {
updateDmListUI(msgDmId, true, Date.now().toString());
updateDmListUI(msgDmId, true, msgTypeRoom, Date.now().toString());
} else if (msgType === "update") {
// do nothing to UI sorting/read state
}
@ -3246,7 +3768,8 @@ async function togglePinMessage(msgId) {
string1: currentDmId,
string2: msgId
});
await fetchEncrypted("dm/message/pin/toggle", payload);
let endpoint = window.currentRoomType === 'groups' ? "group/message/pin/toggle" : "dm/message/pin/toggle";
await fetchEncrypted(endpoint, payload);
} catch (e) {
console.error(e);
showBlahNotification("error.network.error");
@ -3270,7 +3793,8 @@ async function deleteMessage(msgId) {
string1: currentDmId,
string2: msgId
};
let res = await fetchEncrypted("dm/message/delete", JSON.stringify(msgPayload));
let endpoint = window.currentRoomType === 'groups' ? "group/message/delete" : "dm/message/delete";
let res = await fetchEncrypted(endpoint, JSON.stringify(msgPayload));
if (res && res.startsWith("error:")) {
showBlahNotification(res);
}
@ -3346,8 +3870,15 @@ async function replyMessage(msgId) {
let content = msg ? msg.content : "";
if (msg && msg.key && msg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, msg.key);
if(k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
content = await decryptAesGcmFromBase64(content, dmKeyBytes);
}
} catch (e) {
}
}
@ -3454,8 +3985,15 @@ async function reactMessagePrompt(msgId, quickReaction = null) {
let rxVal = rx.r;
if (currentMsg.key && currentMsg.key !== "") {
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, currentMsg.key);
if(k) keyToUse = k;
}
if (keyToUse) {
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
rxVal = await decryptAesGcmFromBase64(rxVal, dmKeyBytes);
}
} catch(e) {}
}
if (rx.u === id && rxVal === reaction) {
@ -3468,10 +4006,17 @@ async function reactMessagePrompt(msgId, quickReaction = null) {
}
let isAdding = (myReactionId === null);
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let keyToUse = currentDmKey;
if (window.currentRoomType === 'groups') {
let k = await getGroupKey(currentDmId, currentMsg.key);
if(k) keyToUse = k;
}
let dmKeyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let encryptedReaction = await encryptAesGcmToBase64(reaction, dmKeyBytes);
let endpoint = isAdding ? "dm/message/react/add" : "dm/message/react/remove";
let endpoint = window.currentRoomType === 'groups'
? (isAdding ? "group/message/react/add" : "group/message/react/remove")
: (isAdding ? "dm/message/react/add" : "dm/message/react/remove");
let msgPayload = {
string1: currentDmId,
string2: msgId,
@ -3615,7 +4160,7 @@ async function changePassword() {
}
async function deleteAccount() {
if (!confirm(processBlah("desc.delete.account") + "\n\nAre you sure?")) return;
if (!confirm(processBlah("desc.delete.account") + "\n\n" + processBlah("prompt.are.you.sure"))) return;
showAction("action.account.deleting", "accountdel");
let res = await fetchPostEnc(`${url}/deleteaccount?id=${window.id}`, "");
clearAction("accountdel");
@ -3978,19 +4523,52 @@ if (container) {
document.addEventListener('touchend', endDrag);
}
function updateDmListUI(dmId, isRead, timestamp = null) {
function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
try {
if (!window.cachedDms) return;
let parsedDms = JSON.parse(window.cachedDms);
if (!parsedDms.dms || !parsedDms.dms[dmId]) return;
if (type === 'groups') {
if (!window.cachedGroups) return;
let parsedGroups = JSON.parse(window.cachedGroups);
if (!parsedGroups.groups || !parsedGroups.groups[roomId]) return;
let changed = false;
if (parsedDms.dms[dmId].string3 !== (isRead ? "true" : "false")) {
parsedDms.dms[dmId].string3 = isRead ? "true" : "false";
if (parsedGroups.groups[roomId].string3 !== (isRead ? "true" : "false")) {
parsedGroups.groups[roomId].string3 = isRead ? "true" : "false";
changed = true;
}
if (timestamp && parsedDms.dms[dmId].string2 !== timestamp) {
parsedDms.dms[dmId].string2 = timestamp;
if (timestamp && parsedGroups.groups[roomId].string2 !== timestamp) {
parsedGroups.groups[roomId].string2 = timestamp;
changed = true;
}
if (changed) {
window.cachedGroups = JSON.stringify(parsedGroups);
}
let btn = document.getElementById(`group-btn-${roomId}`);
let container = document.getElementById("groups-list");
if (btn && container) {
let nameSpan = btn.querySelector('.room-name');
if (nameSpan) {
nameSpan.style.opacity = isRead ? "0.8" : "1";
}
if (timestamp) {
container.prepend(btn);
}
} else {
if (typeof refreshGroups === 'function') refreshGroups(0, false);
}
} else {
if (!window.cachedDms) return;
let parsedDms = JSON.parse(window.cachedDms);
if (!parsedDms.dms || !parsedDms.dms[roomId]) return;
let changed = false;
if (parsedDms.dms[roomId].string3 !== (isRead ? "true" : "false")) {
parsedDms.dms[roomId].string3 = isRead ? "true" : "false";
changed = true;
}
if (timestamp && parsedDms.dms[roomId].string2 !== timestamp) {
parsedDms.dms[roomId].string2 = timestamp;
changed = true;
}
@ -3998,7 +4576,7 @@ function updateDmListUI(dmId, isRead, timestamp = null) {
window.cachedDms = JSON.stringify(parsedDms);
}
let btn = document.getElementById(`dm-btn-${dmId}`);
let btn = document.getElementById(`dm-btn-${roomId}`);
let container = document.getElementById("dms-list");
if (btn && container) {
let nameSpan = btn.querySelector('.room-name');
@ -4011,15 +4589,21 @@ function updateDmListUI(dmId, isRead, timestamp = null) {
} else {
if (typeof refreshDms === 'function') refreshDms(0, false);
}
}
} catch (e) {
console.error(e);
if (typeof refreshDms === 'function') refreshDms(0, false);
if (typeof refreshGroups === 'function') refreshGroups(0, false);
}
}
function markDmAsRead(dmId) {
fetchEncrypted("dm/read", JSON.stringify({string1: dmId})).catch(()=>{});
updateDmListUI(dmId, true);
function markDmAsRead(roomId, type = 'dms') {
if (type === 'groups') {
fetchEncrypted("group/read", JSON.stringify({string1: roomId})).catch(()=>{});
} else {
fetchEncrypted("dm/read", JSON.stringify({string1: roomId})).catch(()=>{});
}
updateDmListUI(roomId, true, type);
}
function handleDmInteraction() {
@ -4029,10 +4613,19 @@ function handleDmInteraction() {
if (lastChatScroll <= 10) {
try {
if (window.currentRoomType === 'groups') {
if (window.cachedGroups) {
let parsedGroups = JSON.parse(window.cachedGroups);
if (parsedGroups.groups && parsedGroups.groups[currentDmId] && parsedGroups.groups[currentDmId].string3 !== "true") {
markDmAsRead(currentDmId, window.currentRoomType);
}
}
} else {
if (window.cachedDms) {
let parsedDms = JSON.parse(window.cachedDms);
if (parsedDms.dms && parsedDms.dms[currentDmId] && parsedDms.dms[currentDmId].string3 !== "true") {
markDmAsRead(currentDmId);
markDmAsRead(currentDmId, window.currentRoomType);
}
}
}
} catch (e) {}
@ -4056,7 +4649,8 @@ async function showPinnedMessages(forceReload = false, showPanel = true) {
}
try {
let res = await fetchEncrypted("dm/messages/pinned", currentDmId);
let endpoint = window.currentRoomType === 'groups' ? "group/messages/pinned" : "dm/messages/pinned";
let res = await fetchEncrypted(endpoint, currentDmId);
if (res && !res.startsWith("error:")) {
let msgs = JSON.parse(res);
let entries = Object.entries(msgs).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));

View file

@ -117,10 +117,10 @@ var notifisScreen = `
var invitesEntry = `
<div class="invite-entry">
<div class="invite-info">
<img src="{pfp}" class="invite-pfp" onclick="openProfile('{id}')" style="cursor: pointer;">
<img src="{pfp}" class="invite-pfp" {onclick_attr}>
<div class="invite-details">
<span class="invite-name" onclick="openProfile('{id}')" style="cursor: pointer;">{username}</span>
<span class="invite-desc"><blah>{desc}</blah></span>
<span class="invite-name" {onclick_attr}>{username}</span>
<span class="invite-desc">{desc}</span>
</div>
</div>
<div class="invite-actions">

View file

@ -215,6 +215,25 @@ sidebar {
width: calc(var(--icon-button-height) + (var(--button-margin) * 2) + var(--border-width));
border-right: var(--border-width) solid var(--light-border-color);
}
sidebar:not(.second) {
overflow-y: auto;
overflow-x: hidden;
}
sidebar:not(.second)::-webkit-scrollbar {
display: none;
}
sidebar:not(.second) {
-ms-overflow-style: none;
scrollbar-width: none;
}
#roomsbar roomcontent2 {
overflow-y: auto;
overflow-x: hidden;
}
sidebar.second {
width: 17rem;
min-width: 17rem;