Compare commits

...

3 commits

Author SHA1 Message Date
55760ba368 bug fixes & ui improvements
All checks were successful
Android Build / publish (push) Successful in 53s
Linux Build / publish (push) Successful in 1m1s
2026-08-10 02:17:56 +02:00
08ff4755a1 group settings (TEST) 2026-08-09 16:51:37 +02:00
bb135bed65 slightly improve popups 2026-08-09 13:21:57 +02:00
16 changed files with 858 additions and 192 deletions

View file

@ -262,5 +262,10 @@
"action.leaving.group": "Leaving group, meow...",
"action.key.rotating": "Rotating keys, meow...",
"action.key.rotation": "Key rotation, meow",
"desc.leave.group.confirm": "Are you sure you want to leave this group, meow?"
"desc.leave.group.confirm": "Are you sure you want to leave this group, meow?",
"desc.key.rotation.you": "You will generate a new encryption key for this group, meow.",
"label.keys.to.encrypt": "keys to encrypt, meow",
"action.group.creating": "Creating group, meow...",
"title.group.settings": "Group settings, meow",
"title.leave.group": "Leave group, meow"
}

View file

@ -249,6 +249,13 @@
"group.user.joined": "Joined group",
"prompt.invite.username": "Enter username to invite:",
"desc.failed.load.members": "Failed to load members",
"title.group.settings": "Group Settings",
"title.group.name": "Group Name",
"title.group.history.visible": "History visible to newcomers",
"desc.group.history.visible": "New group members will be able to see older messages (disables key rotation)",
"history.visible": "enabled",
"history.hidden": "disabled",
"group.history.changed.by": "{0} changed history visibility to: {1}",
"prompt.are.you.sure": "Are you sure?",
"title.unknown.group": "Unknown Group",
"title.unknown": "Unknown",
@ -262,5 +269,10 @@
"action.leaving.group": "Leaving group...",
"action.key.rotating": "Rotating keys...",
"action.key.rotation": "Key rotation",
"desc.leave.group.confirm": "Are you sure you want to leave this group?"
"desc.leave.group.confirm": "Are you sure you want to leave this group?",
"desc.key.rotation.you": "You will generate a new encryption key for this group.",
"label.keys.to.encrypt": "keys to encrypt",
"action.group.creating": "Creating group...",
"title.group.settings": "Group settings",
"title.leave.group": "Leave group"
}

View file

@ -248,6 +248,13 @@
"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",
"title.group.settings": "Ustawienia grupy",
"title.group.name": "Nazwa grupy",
"title.group.history.visible": "Historia widoczna dla nowych",
"desc.group.history.visible": "Nowi członkowie grupy będą mogli zobaczyć starsze wiadomości (wyłącza rotację kluczy)",
"history.visible": "włączona",
"history.hidden": "wyłączona",
"group.history.changed.by": "{0} zmienił widoczność historii na: {1}",
"prompt.are.you.sure": "Czy na pewno?",
"title.unknown.group": "Nieznana grupa",
"title.unknown": "Nieznany",
@ -262,5 +269,10 @@
"action.leaving.group": "Opuszczanie grupy...",
"action.key.rotating": "Obracanie kluczy...",
"action.key.rotation": "Rotacja kluczy",
"desc.leave.group.confirm": "Czy na pewno chcesz opuścić tę grupę?"
"desc.leave.group.confirm": "Czy na pewno chcesz opuścić tę grupę?",
"desc.key.rotation.you": "Wygenerujesz nowy klucz szyfrujący dla tej grupy.",
"label.keys.to.encrypt": "kluczy do zaszyfrowania",
"action.group.creating": "Tworzenie grupy...",
"title.group.settings": "Ustawienia grupy",
"title.leave.group": "Opuść grupę"
}

View file

@ -30,11 +30,10 @@ const ICONS = {
pin: (props) => ICONS._material(`<path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Zm-286 80h252l-46-46v-314H400v314l-46 46Zm126 0Z"/>`, props),
call: (props) => ICONS._material(`<path d="M798-120q-125 0-247-54.5T329-329Q229-429 174.5-551T120-798q0-18 12-30t30-12h162q14 0 25 9.5t13 22.5l26 140q2 16-1 27t-11 19l-97 98q20 37 47.5 71.5T387-386q31 31 65 57.5t72 48.5l94-94q9-9 23.5-13.5T670-390l138 28q14 4 23 14.5t9 23.5v162q0 18-12 30t-30 12ZM241-600l66-66-17-94h-89q5 41 14 81t26 79Zm358 358q39 17 79.5 27t81.5 13v-88l-94-19-67 67ZM241-600Zm358 358Z"/>`, props),
account: (props) => ICONS._material(`<path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/>`, props),
settings: (props) => ICONS._material(`<path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 167-116.5 283.5T480-80Zm0-157q102 0 174-72t72-174q0-102-72-174t-174-72q-102 0-174 72t-72 174q0 102 72 174t174 72Zm0-80q-68 0-116.5-48.5T315-482q0-68 48.5-116.5T480-647q68 0 116.5 48.5T645-482q0 68-48.5 116.5T480-317Zm0-60q43 0 74-31t31-74q0-43-31-74t-74-31q-43 0-74 31t-31 74q0 43 31 74t74 31Zm0-105Z"/>`, props),
profile: (props) => ICONS._material(`<path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/>`, props),
replyAction: (props) => ICONS._material(`<path d="M760-200v-160q0-50-35-85t-85-35H273l144 144-57 56-240-240 240-240 57 56-144 144h367q83 0 141.5 58.5T840-360v160h-80Z"/>`, props),
react: (props) => ICONS._material(`<path d="M480-260q70 0 126.5-40.5T682-400H278q19 59 75.5 99.5T480-260Zm-160-220q25 0 42.5-17.5T380-540q0-25-17.5-42.5T320-600q-25 0-42.5 17.5T260-540q0 25 17.5 42.5T320-480Zm320 0q25 0 42.5-17.5T700-540q0-25-17.5-42.5T640-600q-25 0-42.5 17.5T580-540q0 25 17.5 42.5T640-480ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/>`, props),
settingsProfile: (props) => ICONS._material(`<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/>`, props),
settings: (props) => ICONS._material(`<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/>`, props),
download: (props) => ICONS._feather(`<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>`, props),
camera: (props) => ICONS._feather(`<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle>`, props),

View file

@ -256,9 +256,12 @@
const roomKey = randomRoomKey256Chars();
const selfWrapped = await wrapRoomKeyForSelf(roomKey);
let historyVisible = document.getElementById("creategroup-history") ? document.getElementById("creategroup-history").checked.toString() : "false";
let payload = JSON.stringify({
string1: groupName,
string2: selfWrapped
string2: selfWrapped,
string3: historyVisible
});
let res = await fetchEncrypted("group/create", payload);

View file

@ -432,7 +432,7 @@ function showConfirmPopup(titleKey, descHtml, confirmActionKey, cancelActionKey,
let popupContainer = document.getElementById("generic-popup-container");
let popupInner = document.getElementById("generic-popup-inner");
let confirmClass = isDestructive ? "popup-btn-danger" : "popup-btn-confirm";
let confirmClass = isDestructive ? "popup-btn-danger" : "";
genericPopupCancelCallback = onCancel;
@ -449,10 +449,10 @@ function showConfirmPopup(titleKey, descHtml, confirmActionKey, cancelActionKey,
${descHtml}
</div>
<div style="display: flex; gap: 0.5rem; justify-content: center; width: 100%;">
<button class="profile-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<button class="profile-action-btn popup-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<blah>${cancelActionKey}</blah>
</button>
<button class="profile-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<button class="profile-action-btn popup-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<blah>${confirmActionKey}</blah>
</button>
</div>
@ -474,7 +474,7 @@ function showPromptPopup(titleKey, descHtml, placeholderKey, confirmActionKey, c
let popupContainer = document.getElementById("generic-popup-container");
let popupInner = document.getElementById("generic-popup-inner");
let confirmClass = isDestructive ? "popup-btn-danger" : "popup-btn-confirm";
let confirmClass = isDestructive ? "popup-btn-danger" : "";
genericPopupCancelCallback = onCancel;
@ -491,12 +491,15 @@ function showPromptPopup(titleKey, descHtml, placeholderKey, confirmActionKey, c
<div style="width: 100%; text-align: center; color: var(--text-color); opacity: 0.8; margin-top: 0.5rem; margin-bottom: 1rem;">
${descHtml}
</div>
<input type="text" id="generic-popup-input" class="forminput" placeholder="{blah(${placeholderKey})}" style="width: 100%; margin-bottom: 1rem; box-sizing: border-box;" onkeydown="if(arguments[0].key === 'Enter') { if(genericPopupConfirmCallback) genericPopupConfirmCallback(); }">
<div class="input-group" style="margin: 0 auto 1.5rem auto; width: 100%;">
<label for="generic-popup-input" style="margin-left: calc((var(--button-margin) * 2) - 0.6rem);"><blah>${placeholderKey}</blah></label>
<input type="text" id="generic-popup-input" class="forminput" placeholder="${processBlah(placeholderKey)}" style="margin-left: 0; margin-right: 0; width: 100%; box-sizing: border-box;" onkeydown="if(arguments[0].key === 'Enter') { if(genericPopupConfirmCallback) genericPopupConfirmCallback(); }">
</div>
<div style="display: flex; gap: 0.5rem; justify-content: center; width: 100%;">
<button class="profile-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<button class="profile-action-btn popup-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<blah>${cancelActionKey}</blah>
</button>
<button class="profile-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<button class="profile-action-btn popup-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<blah>${confirmActionKey}</blah>
</button>
</div>
@ -531,7 +534,7 @@ function closeRotationPopup() {
async function showRotationPopup(groupId, originalMessagePayload) {
pendingRotationMessagePayload = originalMessagePayload;
showAction("action.key.rotation", "key_rotation");
showAction("action.key.rotating", "key_rotation");
try {
let membersRaw = await fetchEncrypted("group/members", groupId);
@ -539,7 +542,7 @@ async function showRotationPopup(groupId, originalMessagePayload) {
let members = JSON.parse(membersRaw);
let descHtml = `<blah>desc.key.rotation.info</blah><br><br><b>${members.length} <blah>label.members</blah></b>`;
let descHtml = `<blah>desc.key.rotation.you</blah><br><br><b>${members.length} <blah>label.keys.to.encrypt</blah></b>`;
showConfirmPopup(
"title.key.rotation",
@ -557,6 +560,7 @@ async function showRotationPopup(groupId, originalMessagePayload) {
async function executeKeyRotation() {
closeRotationPopup();
clearAction("key_rotation");
showAction("action.key.rotating", "key_rotation_exec");
try {
@ -613,29 +617,12 @@ async function executeKeyRotation() {
clearAction("key_rotation_exec");
showBlahNotification("success:key.rotated");
// Retry sending the message!
if(pendingRotationMessagePayload) {
let p = pendingRotationMessagePayload;
p.string5 = newIndex;
let dmKeyBytes = await sha256Bytes(base64ToUint8(roomKey));
p.string2 = await encryptAesGcmToBase64(p.rawPlainContent, dmKeyBytes); // we need to save rawPlainContent for retry
showAction("action.message.sending", "msgsending");
let resSend = await fetchEncrypted("group/message/send", JSON.stringify(p));
clearAction("msgsending");
if(resSend && resSend.startsWith("error:")) {
showBlahNotification(resSend);
} else if(resSend && resSend.startsWith("success:")) {
loadDmMessages(currentDmId);
}
}
pendingRotationMessagePayload = null;
} catch(e) {
clearAction("key_rotation_exec");
console.error(e);
showBlahNotification(e.message && e.message.startsWith("error:") ? e.message : "error:something.wrong");
showBlahNotification(e.message || e);
pendingRotationMessagePayload = null;
}
}
@ -2141,7 +2128,7 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
let displayName = username.split(':')[0] || onlyId;
let displayName = username || onlyId;
if (type === "received") {
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = `
@ -2195,7 +2182,7 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
let displayName = username.split(':')[0] || onlyId;
let displayName = username || onlyId;
let val1 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${displayName}</b></span>`;
let val2 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${onlyId}</b></span>`;
@ -2399,6 +2386,13 @@ function gotoJoinSpace() {
}
async function gotoHome(splash = true) {
if (dmMessagePollInterval) {
clearInterval(dmMessagePollInterval);
dmMessagePollInterval = null;
}
currentDmId = null;
window.currentRoomType = null;
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarHomeIndicator);
if (splash) {
@ -2591,6 +2585,8 @@ async function renderDms(res) {
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
html += `
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${targetId}')">
<img src="${pfp}" class="room-pfp">
@ -2598,6 +2594,7 @@ async function renderDms(res) {
<span class="room-name" style="${nameStyle}">${displayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
</div>
${unreadBadge}
</button>
`;
count++;
@ -2651,6 +2648,8 @@ async function renderGroups(res) {
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName);
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
html += `
<button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${groupId}')">
<img src="${pfp}" class="room-pfp">
@ -2658,6 +2657,7 @@ async function renderGroups(res) {
<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>
${unreadBadge}
</button>
`;
count++;
@ -2742,7 +2742,7 @@ async function inviteToGroupPrompt(groupId) {
showPromptPopup(
"action.invite",
"",
"prompt.invite.username",
"title.username",
"action.invite",
"action.cancel",
async (targetUsername) => {
@ -2762,9 +2762,63 @@ async function inviteToGroupPrompt(groupId) {
try {
showAction("action.invite.sending", "invite.action");
let keysPayload = "";
try {
let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && !infoRes.startsWith("error:")) {
let info = JSON.parse(infoRes);
if (info.string6 === "true") { // historyVisible
let lastKeyIndex = parseInt(info.string4);
if (!isNaN(lastKeyIndex)) {
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`);
if (publicKeysRaw && publicKeysRaw.length > 10) {
let publicKeys = JSON.parse(publicKeysRaw);
let hybrid = await deriveHybridKeyToUser(publicKeys);
let myPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${window.id}`);
let keysDict = {};
let allKeysSuccess = true;
for (let i = 0; i <= lastKeyIndex; i++) {
let roomKey = await getGroupKey(groupId, i.toString());
if (!roomKey) {
allKeysSuccess = false;
break;
}
let encForMember = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
let setupPayloadJson = JSON.stringify({
v: 1,
alg: "hybrid-x25519-mlkem768",
ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForMember
});
keysDict[i.toString()] = `SETUP:${myPublicKeysRaw};${setupPayloadJson}`;
}
if (!allKeysSuccess) {
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
keysPayload = JSON.stringify(keysDict);
} else {
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
}
}
}
} catch (e) {
console.error("Error generating keys payload for invite", e);
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
let payload = JSON.stringify({
string1: groupId,
string2: targetId
string2: targetId,
string3: keysPayload
});
let res = await fetchEncrypted("group/invite", payload);
@ -2857,10 +2911,10 @@ async function openDm(dmId, username, targetId, type = 'dms') {
let iconHtml = "";
if (type === 'dms') {
let pfp = await getAvatarUrl(targetId, username);
iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
} else {
let pfp = await getGroupAvatarUrl(dmId, username);
iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
}
if (currentRoomsBarTitle !== "title.home") {
@ -3018,8 +3072,27 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
if (atts.length > 0) {
attachmentsHtml += `<div class="chat-message-attachments" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">`;
for (let att of atts) {
if (att.enc && att.enc.startsWith("ENC:")) {
try {
if (keyToUse) {
let keyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let decStr = await decryptAesGcmFromBase64(att.enc.substring(4), keyBytes);
let metadata = JSON.parse(decStr);
att.name = metadata.name || "unknown";
att.type = metadata.type || "application/octet-stream";
att.size = metadata.size || 0;
} else {
att.name = processBlah("error:messages.decrypt.failed");
att.type = "application/octet-stream";
}
} catch(e) {
att.name = processBlah("error:messages.decrypt.failed");
att.type = "application/octet-stream";
}
}
let attContainerId = `att-container-${msgId}-${att.id}`;
let isMedia = att.type.startsWith("image/") || att.type.startsWith("video/");
let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/"));
let sizeStr = att.size ? formatBytes(att.size) : "";
let displayName = truncateFilename(att.name, 25);
let iconSvg = isMedia
@ -3060,7 +3133,7 @@ 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" || msg.type === "larp.info.join" || msg.type === "larp.info.leave" || msg.type === "larp.info.create") {
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" || msg.type === "larp.info.history") {
let authorName = processBlah("title.unknown");
if (msg.author && msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
@ -3083,6 +3156,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
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";
else if (msg.type === "larp.info.history") actionStr = "group.history.changed.by";
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
@ -3090,6 +3164,8 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
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 if (msg.type === "larp.info.history") {
content = textStr.replace("{0}", clickableAuthor).replace("{1}", content);
} else {
content = textStr.replace("{0}", clickableAuthor);
}
@ -3521,7 +3597,10 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name,
}
} catch (e) {
console.error(e);
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading</div>`;
if (window.currentRoomType === 'groups' && window.groupKeysCache[currentDmId]) {
delete window.groupKeysCache[currentDmId][keyIndex];
}
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading - click to retry</div>`;
container.dataset.loading = "false";
}
}
@ -3648,12 +3727,17 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
}
if (decText && decText.startsWith("success:")) {
attachmentsPayload.push({
id: i,
let metadataObj = {
type: file.type,
name: file.name,
size: file.size,
staging_id: decText.split(":")[1]
size: file.size
};
let encMetadata = "ENC:" + await encryptAesGcmToBase64(JSON.stringify(metadataObj), dmKeyBytes);
attachmentsPayload.push({
id: i,
staging_id: decText.split(":")[1],
enc: encMetadata
});
} else {
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
@ -3671,9 +3755,11 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
if (window.currentRoomType === 'groups') {
msgPayload.string5 = window.currentGroupKeyIndex || "0";
msgPayload.rawPlainContent = content; // for retry
}
let savedContentForRestore = content;
let savedAttachmentsForRestore = [...currentDraftAttachments];
input.value = "";
input.style.height = '2.5rem';
sendbutton.style.height = '2.5rem';
@ -3693,7 +3779,13 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
showRotationPopup(currentDmId, msgPayload);
// Restore input and attachments for the user to retry manually
input.value = savedContentForRestore;
currentDraftAttachments = savedAttachmentsForRestore;
renderAttachmentsPreview();
showRotationPopup(currentDmId, null);
} else if (res && res.startsWith("error:")) {
showBlahNotification(res);
let pElem = document.getElementById(pendingMsgId);
@ -4282,7 +4374,12 @@ async function changePassword() {
}
async function deleteAccount() {
if (!confirm(processBlah("desc.delete.account") + "\n\n" + processBlah("prompt.are.you.sure"))) return;
showConfirmPopup(
"title.delete.account",
"<blah>desc.delete.account</blah><br><br><blah>prompt.are.you.sure</blah>",
"action.delete.account",
"action.cancel",
async () => {
showAction("action.account.deleting", "accountdel");
let res = await fetchPostEnc(`${url}/deleteaccount?id=${window.id}`, "");
clearAction("accountdel");
@ -4293,6 +4390,10 @@ async function deleteAccount() {
} else {
showBlahNotification(text);
}
},
null,
true
);
}
async function saveProfile() {
@ -4349,8 +4450,8 @@ function initCropper() {
let zoomSlider = document.getElementById('cropper-zoom');
// Set aspect ratio
let targetWidth = cropperType === 'avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? targetWidth : 200;
let aspectRatio = targetWidth / targetHeight;
container.style.height = 'auto';
@ -4418,8 +4519,8 @@ async function confirmCropper() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let container = document.getElementById('cropper-viewport-container');
let targetWidth = cropperType === 'avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' ? 512 : 400;
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 400;
let scaleRatio = targetWidth / container.clientWidth;
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
@ -4590,10 +4691,20 @@ async function confirmCropper() {
newSrc = 'data:image/webp;base64,' + b64;
}
let payloadKey = cropperType === 'avatar' ? 'larp.profile.pfp' : 'larp.profile.banner';
let fileBytes = base64ToUint8(b64);
let payloadKey = (cropperType === 'avatar' || cropperType === 'group-avatar') ? 'larp.profile.pfp' : 'larp.profile.banner';
showAction("action.uploading", "uploadmedia");
let res = await fetchEncryptedUpload(payloadKey, fileBytes);
let res;
if (cropperType === 'group-avatar') {
let payload = JSON.stringify({
string1: window.currentSettingsGroupId,
string2: payloadKey,
string3: b64
});
res = await fetchEncrypted("group/storage/public/update", payload);
} else {
let fileBytes = base64ToUint8(b64);
res = await fetchEncryptedUpload(payloadKey, fileBytes);
}
clearAction("uploadmedia");
if (res && res.startsWith("error:")) {
showBlahNotification(res);
@ -4603,9 +4714,15 @@ async function confirmCropper() {
// Update all matching images on the page instantly
document.querySelectorAll('img').forEach(img => {
if (cropperType === 'group-avatar') {
if (img.src.includes(`id=${window.currentSettingsGroupId}&e=${payloadKey}`) || img.id === 'group-settings-avatar-preview') {
img.src = newSrc;
}
} else {
if (img.src.includes(`id=${window.id}&e=${payloadKey}`) || img.id === `settings-${cropperType}-preview` || (cropperType === 'avatar' && (img.id === 'my-avatar-img' || img.id === 'sidebar-pfp'))) {
img.src = newSrc;
}
}
});
}
@ -4673,6 +4790,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
if (nameSpan) {
nameSpan.style.opacity = isRead ? "0.8" : "1";
}
let badge = btn.querySelector('.unread-badge');
if (badge) {
if (isRead) badge.classList.remove("show");
else badge.classList.add("show");
}
if (timestamp) {
container.prepend(btn);
}
@ -4705,6 +4827,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
if (nameSpan) {
nameSpan.style.opacity = isRead ? "0.8" : "1";
}
let badge = btn.querySelector('.unread-badge');
if (badge) {
if (isRead) badge.classList.remove("show");
else badge.classList.add("show");
}
if (timestamp) {
container.prepend(btn);
}
@ -4927,9 +5054,48 @@ function handleGroupContextMenu(e, groupId) {
fixedContextMenu.style.top = `${newTop}px`;
}
function gotoGroupSettings(groupId) {
async function gotoGroupSettings(groupId) {
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
// Not implemented yet
window.currentSettingsGroupId = groupId;
let infoStr = await fetchEncrypted("group/info", groupId);
if (!infoStr.startsWith("error:")) {
let info = JSON.parse(infoStr);
await switchRoomContent("title.group.settings", groupSettingsScreen, false);
document.getElementById('group-settings-name').value = info.string2;
let pfpUrl = await getGroupAvatarUrl(groupId, info.string2);
document.getElementById('group-settings-avatar-preview').src = pfpUrl;
document.getElementById('group-settings-history').checked = (info.string6 === "true");
} else {
showBlahNotification(infoStr);
}
}
async function saveGroupSettings() {
let groupId = window.currentSettingsGroupId;
let name = document.getElementById('group-settings-name').value;
let historyVisible = document.getElementById('group-settings-history').checked.toString();
let payload = JSON.stringify({
string1: groupId,
string2: name,
string3: historyVisible
});
showAction("action.saving", "savegroup");
let res = await fetchEncrypted("group/settings/update", payload);
clearAction("savegroup");
if (res === "success") {
showBlahNotification("success:group.settings.saved");
refreshGroups(0, false);
} else {
showBlahNotification(res);
}
}
function leaveGroupPrompt(groupId) {

View file

@ -31,8 +31,18 @@ var createGroupScreen = `
<div class="input-group">
<label for="creategroup-name"><blah>title.name</blah></label>
<input type="text" id="creategroup-name" placeholder="{blah(placeholder.create.group.input)}" class="forminput">
<div class="setting-row" style="text-align: left;">
<div class="setting-info">
<span class="setting-title"><blah>title.group.history.visible</blah></span>
<span class="setting-desc"><blah>desc.group.history.visible</blah></span>
</div>
<div class="input-group">
<label class="toggle-switch">
<input type="checkbox" id="creategroup-history">
<span class="toggle-slider"></span>
</label>
</div>
<button class="submit-button blah" onclick="createGroup()">title.create.group</button>
</div>
</div>
@ -211,7 +221,7 @@ var profileContextMenu = `
<span class="blah">title.profile</span>
</button>
<button onclick="gotoSettings()">
${ICONS.settingsProfile()}
${ICONS.settings()}
<span class="blah">title.settings</span>
</button>
<button onclick="logout()">
@ -327,7 +337,7 @@ var accountSettings = `
<div class="input-group">
<label style="color: var(--big-red);"><blah>title.delete.account</blah></label>
<p style="font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.5rem; margin-left: 0.6rem;"><blah>desc.delete.account</blah></p>
<button class="submit-button blah" onclick="deleteAccount()" style="background: var(--big-red); color: white;">action.delete.account</button>
<button class="submit-button popup-btn-danger blah" onclick="deleteAccount()">action.delete.account</button>
</div>
</div>
`;
@ -368,6 +378,36 @@ var profileSettings = `
</div>
</div>
`;
var groupSettingsScreen = `
<div class="auth-box popup-auto-height" style="margin-top:2rem">
<div class="input-group" style="display: flex; flex-direction: row; align-items: stretch; justify-content: space-between; border: var(--border-width) solid var(--light-border-color); border-radius: 0.8rem; padding: 1rem; width: 100%; max-width: 30rem;">
<img id="group-settings-avatar-preview" src="" style="width: 4.5rem; height: 4.5rem; border-radius: 0.8rem; border: var(--border-width) solid rgba(255, 255, 255, 0.2); object-fit: cover; background: var(--main-bg-color);">
<div style="display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; padding: 0.1rem 0;">
<div style="font-weight: bold;"><blah>title.avatar</blah></div>
<button class="submit-button blah" style="margin: 0; padding: 0.5rem 1rem; width: auto;" onclick="document.getElementById('group-settings-avatar-input').click()">action.change.avatar</button>
<input type="file" id="group-settings-avatar-input" style="display: none;" accept="image/*" onchange="openCropper(this, 'group-avatar')">
</div>
</div>
<br>
<div class="input-group">
<label for="group-settings-name"><blah>title.group.name</blah></label>
<input type="text" id="group-settings-name" class="forminput">
<div class="setting-row">
<div class="setting-info">
<span class="setting-title"><blah>title.group.history.visible</blah></span>
<span class="setting-desc"><blah>desc.group.history.visible</blah></span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="group-settings-history">
<span class="toggle-slider"></span>
</label>
</div>
<button class="submit-button blah" onclick="saveGroupSettings()">action.save</button>
</div>
</div>
`;
var appearanceSettings = `
<div style="display: flex;justify-content: center;align-items: center;height:100%;flex-direction: column;text-align: center;">

View file

@ -366,6 +366,7 @@ herotitle {
gap: 0.2rem;
width: 90%;
max-width: 30rem;
margin: 0 auto;
}
.input-group label {
font-size: 0.75rem;
@ -878,35 +879,27 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
height: auto;
max-height: 85vh;
}
.popup-btn-cancel {
button.profile-action-btn.popup-action-btn {
flex: 1;
display: flex;
justify-content: center;
margin-top: 0;
}
button.profile-action-btn.popup-btn-cancel {
background: rgba(255, 255, 255, 0.05);
color: var(--text-color);
border: var(--border-width) solid var(--light-border-color);
flex: 1;
justify-content: center;
}
.popup-btn-cancel:hover {
button.profile-action-btn.popup-btn-cancel:hover {
background: rgba(255, 255, 255, 0.1);
}
.popup-btn-confirm {
background: var(--text-color);
color: var(--main-bg-color);
button.profile-action-btn.popup-btn-danger, button.submit-button.popup-btn-danger {
background: var(--big-red) !important;
color: white !important;
border: none;
flex: 1;
justify-content: center;
}
.popup-btn-confirm:hover {
background: white;
}
.popup-btn-danger {
background: var(--big-red);
color: white;
border: none;
flex: 1;
justify-content: center;
}
.popup-btn-danger:hover {
background: #ff5252;
button.profile-action-btn.popup-btn-danger:hover, button.submit-button.popup-btn-danger:hover {
filter: brightness(0.85);
}
.profile-popup-container.show .profile-popup-content {
transform: scale(1);
@ -1072,3 +1065,106 @@ body.hide-topbar-pin #roomdetailsbar roomtopbar .room-action-pin { display: flex
body.hide-topbar-call roomcontent roomtopbar .room-action-call { display: none !important; }
body.hide-topbar-call #roomdetailsbar roomtopbar .room-action-call { display: flex !important; }
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 3.4rem;
height: 1.8rem;
margin: var(--button-margin);
flex-shrink: 0;
}
label.toggle-switch {
opacity: 0.8;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.1);
transition: .4s;
border-radius: 1.8rem;
border: var(--border-width) solid var(--light-border-color);
}
.toggle-slider:before {
position: absolute;
content: "";
height: 1.2rem;
width: 1.2rem;
left: 0.25rem;
top: 50%;
transform: translateY(-50%);
background-color: var(--text-color);
transition: .4s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--text-color) !important;
border-color: var(--text-color) !important;
}
input:checked + .toggle-slider:before {
transform: translate(1.6rem, -50%);
background-color: var(--main-bg-color) !important;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
width: auto;
box-sizing: border-box;
margin: var(--button-margin);
padding: 0.5rem;
border-radius: 0.8rem;
background-color: rgba(255, 255, 255, 0.02);
}
.setting-row:hover {
background-color: rgba(255, 255, 255, 0.04);
}
.setting-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.setting-title {
font-size: 1.1rem;
font-weight: bold;
}
.setting-desc {
font-size: 0.85rem;
color: var(--darker-text-color);
margin-top: 0.2rem;
}
.unread-badge {
width: 0.65rem;
height: 0.65rem;
min-width: 0.65rem;
min-height: 0.65rem;
background-color: var(--big-red);
border-radius: 50%;
flex-shrink: 0;
margin-right: 0.5rem;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: scale(0);
}
.unread-badge.show {
transform: scale(1);
animation: badge-appear 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes badge-appear {
0% { transform: scale(0); }
100% { transform: scale(1); }
}

View file

@ -262,5 +262,10 @@
"action.leaving.group": "Leaving group, meow...",
"action.key.rotating": "Rotating keys, meow...",
"action.key.rotation": "Key rotation, meow",
"desc.leave.group.confirm": "Are you sure you want to leave this group, meow?"
"desc.leave.group.confirm": "Are you sure you want to leave this group, meow?",
"desc.key.rotation.you": "You will generate a new encryption key for this group, meow.",
"label.keys.to.encrypt": "keys to encrypt, meow",
"action.group.creating": "Creating group, meow...",
"title.group.settings": "Group settings, meow",
"title.leave.group": "Leave group, meow"
}

View file

@ -249,6 +249,13 @@
"group.user.joined": "Joined group",
"prompt.invite.username": "Enter username to invite:",
"desc.failed.load.members": "Failed to load members",
"title.group.settings": "Group Settings",
"title.group.name": "Group Name",
"title.group.history.visible": "History visible to newcomers",
"desc.group.history.visible": "New group members will be able to see older messages (disables key rotation)",
"history.visible": "enabled",
"history.hidden": "disabled",
"group.history.changed.by": "{0} changed history visibility to: {1}",
"prompt.are.you.sure": "Are you sure?",
"title.unknown.group": "Unknown Group",
"title.unknown": "Unknown",
@ -262,5 +269,10 @@
"action.leaving.group": "Leaving group...",
"action.key.rotating": "Rotating keys...",
"action.key.rotation": "Key rotation",
"desc.leave.group.confirm": "Are you sure you want to leave this group?"
"desc.leave.group.confirm": "Are you sure you want to leave this group?",
"desc.key.rotation.you": "You will generate a new encryption key for this group.",
"label.keys.to.encrypt": "keys to encrypt",
"action.group.creating": "Creating group...",
"title.group.settings": "Group settings",
"title.leave.group": "Leave group"
}

View file

@ -248,6 +248,13 @@
"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",
"title.group.settings": "Ustawienia grupy",
"title.group.name": "Nazwa grupy",
"title.group.history.visible": "Historia widoczna dla nowych",
"desc.group.history.visible": "Nowi członkowie grupy będą mogli zobaczyć starsze wiadomości (wyłącza rotację kluczy)",
"history.visible": "włączona",
"history.hidden": "wyłączona",
"group.history.changed.by": "{0} zmienił widoczność historii na: {1}",
"prompt.are.you.sure": "Czy na pewno?",
"title.unknown.group": "Nieznana grupa",
"title.unknown": "Nieznany",
@ -262,5 +269,10 @@
"action.leaving.group": "Opuszczanie grupy...",
"action.key.rotating": "Obracanie kluczy...",
"action.key.rotation": "Rotacja kluczy",
"desc.leave.group.confirm": "Czy na pewno chcesz opuścić tę grupę?"
"desc.leave.group.confirm": "Czy na pewno chcesz opuścić tę grupę?",
"desc.key.rotation.you": "Wygenerujesz nowy klucz szyfrujący dla tej grupy.",
"label.keys.to.encrypt": "kluczy do zaszyfrowania",
"action.group.creating": "Tworzenie grupy...",
"title.group.settings": "Ustawienia grupy",
"title.leave.group": "Opuść grupę"
}

View file

@ -30,11 +30,10 @@ const ICONS = {
pin: (props) => ICONS._material(`<path d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Zm-286 80h252l-46-46v-314H400v314l-46 46Zm126 0Z"/>`, props),
call: (props) => ICONS._material(`<path d="M798-120q-125 0-247-54.5T329-329Q229-429 174.5-551T120-798q0-18 12-30t30-12h162q14 0 25 9.5t13 22.5l26 140q2 16-1 27t-11 19l-97 98q20 37 47.5 71.5T387-386q31 31 65 57.5t72 48.5l94-94q9-9 23.5-13.5T670-390l138 28q14 4 23 14.5t9 23.5v162q0 18-12 30t-30 12ZM241-600l66-66-17-94h-89q5 41 14 81t26 79Zm358 358q39 17 79.5 27t81.5 13v-88l-94-19-67 67ZM241-600Zm358 358Z"/>`, props),
account: (props) => ICONS._material(`<path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/>`, props),
settings: (props) => ICONS._material(`<path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 167-116.5 283.5T480-80Zm0-157q102 0 174-72t72-174q0-102-72-174t-174-72q-102 0-174 72t-72 174q0 102 72 174t174 72Zm0-80q-68 0-116.5-48.5T315-482q0-68 48.5-116.5T480-647q68 0 116.5 48.5T645-482q0 68-48.5 116.5T480-317Zm0-60q43 0 74-31t31-74q0-43-31-74t-74-31q-43 0-74 31t-31 74q0 43 31 74t74 31Zm0-105Z"/>`, props),
profile: (props) => ICONS._material(`<path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/>`, props),
replyAction: (props) => ICONS._material(`<path d="M760-200v-160q0-50-35-85t-85-35H273l144 144-57 56-240-240 240-240 57 56-144 144h367q83 0 141.5 58.5T840-360v160h-80Z"/>`, props),
react: (props) => ICONS._material(`<path d="M480-260q70 0 126.5-40.5T682-400H278q19 59 75.5 99.5T480-260Zm-160-220q25 0 42.5-17.5T380-540q0-25-17.5-42.5T320-600q-25 0-42.5 17.5T260-540q0 25 17.5 42.5T320-480Zm320 0q25 0 42.5-17.5T700-540q0-25-17.5-42.5T640-600q-25 0-42.5 17.5T580-540q0 25 17.5 42.5T640-480ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/>`, props),
settingsProfile: (props) => ICONS._material(`<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/>`, props),
settings: (props) => ICONS._material(`<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/>`, props),
download: (props) => ICONS._feather(`<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>`, props),
camera: (props) => ICONS._feather(`<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle>`, props),

View file

@ -256,9 +256,12 @@
const roomKey = randomRoomKey256Chars();
const selfWrapped = await wrapRoomKeyForSelf(roomKey);
let historyVisible = document.getElementById("creategroup-history") ? document.getElementById("creategroup-history").checked.toString() : "false";
let payload = JSON.stringify({
string1: groupName,
string2: selfWrapped
string2: selfWrapped,
string3: historyVisible
});
let res = await fetchEncrypted("group/create", payload);

View file

@ -432,7 +432,7 @@ function showConfirmPopup(titleKey, descHtml, confirmActionKey, cancelActionKey,
let popupContainer = document.getElementById("generic-popup-container");
let popupInner = document.getElementById("generic-popup-inner");
let confirmClass = isDestructive ? "popup-btn-danger" : "popup-btn-confirm";
let confirmClass = isDestructive ? "popup-btn-danger" : "";
genericPopupCancelCallback = onCancel;
@ -449,10 +449,10 @@ function showConfirmPopup(titleKey, descHtml, confirmActionKey, cancelActionKey,
${descHtml}
</div>
<div style="display: flex; gap: 0.5rem; justify-content: center; width: 100%;">
<button class="profile-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<button class="profile-action-btn popup-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<blah>${cancelActionKey}</blah>
</button>
<button class="profile-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<button class="profile-action-btn popup-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<blah>${confirmActionKey}</blah>
</button>
</div>
@ -474,7 +474,7 @@ function showPromptPopup(titleKey, descHtml, placeholderKey, confirmActionKey, c
let popupContainer = document.getElementById("generic-popup-container");
let popupInner = document.getElementById("generic-popup-inner");
let confirmClass = isDestructive ? "popup-btn-danger" : "popup-btn-confirm";
let confirmClass = isDestructive ? "popup-btn-danger" : "";
genericPopupCancelCallback = onCancel;
@ -491,12 +491,15 @@ function showPromptPopup(titleKey, descHtml, placeholderKey, confirmActionKey, c
<div style="width: 100%; text-align: center; color: var(--text-color); opacity: 0.8; margin-top: 0.5rem; margin-bottom: 1rem;">
${descHtml}
</div>
<input type="text" id="generic-popup-input" class="forminput" placeholder="{blah(${placeholderKey})}" style="width: 100%; margin-bottom: 1rem; box-sizing: border-box;" onkeydown="if(arguments[0].key === 'Enter') { if(genericPopupConfirmCallback) genericPopupConfirmCallback(); }">
<div class="input-group" style="margin: 0 auto 1.5rem auto; width: 100%;">
<label for="generic-popup-input" style="margin-left: calc((var(--button-margin) * 2) - 0.6rem);"><blah>${placeholderKey}</blah></label>
<input type="text" id="generic-popup-input" class="forminput" placeholder="${processBlah(placeholderKey)}" style="margin-left: 0; margin-right: 0; width: 100%; box-sizing: border-box;" onkeydown="if(arguments[0].key === 'Enter') { if(genericPopupConfirmCallback) genericPopupConfirmCallback(); }">
</div>
<div style="display: flex; gap: 0.5rem; justify-content: center; width: 100%;">
<button class="profile-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<button class="profile-action-btn popup-action-btn popup-btn-cancel" onclick="closeGenericPopup()">
<blah>${cancelActionKey}</blah>
</button>
<button class="profile-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<button class="profile-action-btn popup-action-btn ${confirmClass}" onclick="if(genericPopupConfirmCallback) genericPopupConfirmCallback()">
<blah>${confirmActionKey}</blah>
</button>
</div>
@ -531,7 +534,7 @@ function closeRotationPopup() {
async function showRotationPopup(groupId, originalMessagePayload) {
pendingRotationMessagePayload = originalMessagePayload;
showAction("action.key.rotation", "key_rotation");
showAction("action.key.rotating", "key_rotation");
try {
let membersRaw = await fetchEncrypted("group/members", groupId);
@ -539,7 +542,7 @@ async function showRotationPopup(groupId, originalMessagePayload) {
let members = JSON.parse(membersRaw);
let descHtml = `<blah>desc.key.rotation.info</blah><br><br><b>${members.length} <blah>label.members</blah></b>`;
let descHtml = `<blah>desc.key.rotation.you</blah><br><br><b>${members.length} <blah>label.keys.to.encrypt</blah></b>`;
showConfirmPopup(
"title.key.rotation",
@ -557,6 +560,7 @@ async function showRotationPopup(groupId, originalMessagePayload) {
async function executeKeyRotation() {
closeRotationPopup();
clearAction("key_rotation");
showAction("action.key.rotating", "key_rotation_exec");
try {
@ -613,29 +617,12 @@ async function executeKeyRotation() {
clearAction("key_rotation_exec");
showBlahNotification("success:key.rotated");
// Retry sending the message!
if(pendingRotationMessagePayload) {
let p = pendingRotationMessagePayload;
p.string5 = newIndex;
let dmKeyBytes = await sha256Bytes(base64ToUint8(roomKey));
p.string2 = await encryptAesGcmToBase64(p.rawPlainContent, dmKeyBytes); // we need to save rawPlainContent for retry
showAction("action.message.sending", "msgsending");
let resSend = await fetchEncrypted("group/message/send", JSON.stringify(p));
clearAction("msgsending");
if(resSend && resSend.startsWith("error:")) {
showBlahNotification(resSend);
} else if(resSend && resSend.startsWith("success:")) {
loadDmMessages(currentDmId);
}
}
pendingRotationMessagePayload = null;
} catch(e) {
clearAction("key_rotation_exec");
console.error(e);
showBlahNotification(e.message && e.message.startsWith("error:") ? e.message : "error:something.wrong");
showBlahNotification(e.message || e);
pendingRotationMessagePayload = null;
}
}
@ -2141,7 +2128,7 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
let displayName = username.split(':')[0] || onlyId;
let displayName = username || onlyId;
if (type === "received") {
desc = `:desc.invite.dm.received:${displayName};${onlyId}`;
actions = `
@ -2195,7 +2182,7 @@ async function renderInvites(res, type) {
let actions = "";
let desc = "";
let displayName = username.split(':')[0] || onlyId;
let displayName = username || onlyId;
let val1 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${displayName}</b></span>`;
let val2 = `<span onclick="openProfile('${targetId}')" style="cursor:pointer"><b>${onlyId}</b></span>`;
@ -2399,6 +2386,13 @@ function gotoJoinSpace() {
}
async function gotoHome(splash = true) {
if (dmMessagePollInterval) {
clearInterval(dmMessagePollInterval);
dmMessagePollInterval = null;
}
currentDmId = null;
window.currentRoomType = null;
if (roomsBarContainer) roomsBarContainer.style.display = ""; //show roombar
setActiveSidebarIndicator(sidebarHomeIndicator);
if (splash) {
@ -2591,6 +2585,8 @@ async function renderDms(res) {
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
html += `
<button class="room-entry" id="dm-btn-${dmId}" onclick="openDm('${dmId}', decodeURIComponent('${safeTargetUsername}'), '${targetId}')">
<img src="${pfp}" class="room-pfp">
@ -2598,6 +2594,7 @@ async function renderDms(res) {
<span class="room-name" style="${nameStyle}">${displayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${targetUsername} (${targetId.split(';')[0]})</span>
</div>
${unreadBadge}
</button>
`;
count++;
@ -2651,6 +2648,8 @@ async function renderGroups(res) {
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName);
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
html += `
<button class="room-entry" id="group-btn-${groupId}" onclick="openDm('${groupId}', decodeURIComponent('${safeGroupName}'), '${groupId}', 'groups')" oncontextmenu="handleGroupContextMenu(event, '${groupId}')">
<img src="${pfp}" class="room-pfp">
@ -2658,6 +2657,7 @@ async function renderGroups(res) {
<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>
${unreadBadge}
</button>
`;
count++;
@ -2742,7 +2742,7 @@ async function inviteToGroupPrompt(groupId) {
showPromptPopup(
"action.invite",
"",
"prompt.invite.username",
"title.username",
"action.invite",
"action.cancel",
async (targetUsername) => {
@ -2762,9 +2762,63 @@ async function inviteToGroupPrompt(groupId) {
try {
showAction("action.invite.sending", "invite.action");
let keysPayload = "";
try {
let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && !infoRes.startsWith("error:")) {
let info = JSON.parse(infoRes);
if (info.string6 === "true") { // historyVisible
let lastKeyIndex = parseInt(info.string4);
if (!isNaN(lastKeyIndex)) {
let publicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${targetId}`);
if (publicKeysRaw && publicKeysRaw.length > 10) {
let publicKeys = JSON.parse(publicKeysRaw);
let hybrid = await deriveHybridKeyToUser(publicKeys);
let myPublicKeysRaw = await fetchAsync(`${url}/user/key/getpublic?id=${window.id}`);
let keysDict = {};
let allKeysSuccess = true;
for (let i = 0; i <= lastKeyIndex; i++) {
let roomKey = await getGroupKey(groupId, i.toString());
if (!roomKey) {
allKeysSuccess = false;
break;
}
let encForMember = await encryptAesGcmToBase64(roomKey, hybrid.aesKeyBytes);
let setupPayloadJson = JSON.stringify({
v: 1,
alg: "hybrid-x25519-mlkem768",
ctMlKem: hybrid.ciphertextMlKemBase64,
enc: encForMember
});
keysDict[i.toString()] = `SETUP:${myPublicKeysRaw};${setupPayloadJson}`;
}
if (!allKeysSuccess) {
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
keysPayload = JSON.stringify(keysDict);
} else {
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
}
}
}
} catch (e) {
console.error("Error generating keys payload for invite", e);
clearAction("invite.action");
showBlahNotification("error:failed.to.prepare.keys");
return;
}
let payload = JSON.stringify({
string1: groupId,
string2: targetId
string2: targetId,
string3: keysPayload
});
let res = await fetchEncrypted("group/invite", payload);
@ -2857,10 +2911,10 @@ async function openDm(dmId, username, targetId, type = 'dms') {
let iconHtml = "";
if (type === 'dms') {
let pfp = await getAvatarUrl(targetId, username);
iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
} else {
let pfp = await getGroupAvatarUrl(dmId, username);
iconHtml = `<img src="${pfp}" style="width: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
iconHtml = `<img src="${pfp}" style="width: 2.3rem;height: 2.3rem; border-radius: 0.65rem; margin-right: 0.38rem; object-fit: cover; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">`;
}
if (currentRoomsBarTitle !== "title.home") {
@ -3018,8 +3072,27 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
if (atts.length > 0) {
attachmentsHtml += `<div class="chat-message-attachments" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">`;
for (let att of atts) {
if (att.enc && att.enc.startsWith("ENC:")) {
try {
if (keyToUse) {
let keyBytes = await sha256Bytes(base64ToUint8(keyToUse));
let decStr = await decryptAesGcmFromBase64(att.enc.substring(4), keyBytes);
let metadata = JSON.parse(decStr);
att.name = metadata.name || "unknown";
att.type = metadata.type || "application/octet-stream";
att.size = metadata.size || 0;
} else {
att.name = processBlah("error:messages.decrypt.failed");
att.type = "application/octet-stream";
}
} catch(e) {
att.name = processBlah("error:messages.decrypt.failed");
att.type = "application/octet-stream";
}
}
let attContainerId = `att-container-${msgId}-${att.id}`;
let isMedia = att.type.startsWith("image/") || att.type.startsWith("video/");
let isMedia = att.type && (att.type.startsWith("image/") || att.type.startsWith("video/"));
let sizeStr = att.size ? formatBytes(att.size) : "";
let displayName = truncateFilename(att.name, 25);
let iconSvg = isMedia
@ -3060,7 +3133,7 @@ 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" || msg.type === "larp.info.join" || msg.type === "larp.info.leave" || msg.type === "larp.info.create") {
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" || msg.type === "larp.info.history") {
let authorName = processBlah("title.unknown");
if (msg.author && msg.author !== "0") {
if (!dmUsernameCache[msg.author]) {
@ -3083,6 +3156,7 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
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";
else if (msg.type === "larp.info.history") actionStr = "group.history.changed.by";
let clickableAuthor = `<span onclick="event.stopPropagation(); openProfile('${msg.author}')" style="cursor: pointer; font-weight: bold;">${authorName}</span>`;
@ -3090,6 +3164,8 @@ async function generateMessagesHtml(entries, isPinnedContext = false) {
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 if (msg.type === "larp.info.history") {
content = textStr.replace("{0}", clickableAuthor).replace("{1}", content);
} else {
content = textStr.replace("{0}", clickableAuthor);
}
@ -3521,7 +3597,10 @@ async function downloadAndRenderAttachment(container, msgId, attId, type, name,
}
} catch (e) {
console.error(e);
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading</div>`;
if (window.currentRoomType === 'groups' && window.groupKeysCache[currentDmId]) {
delete window.groupKeysCache[currentDmId][keyIndex];
}
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading - click to retry</div>`;
container.dataset.loading = "false";
}
}
@ -3648,12 +3727,17 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
}
if (decText && decText.startsWith("success:")) {
attachmentsPayload.push({
id: i,
let metadataObj = {
type: file.type,
name: file.name,
size: file.size,
staging_id: decText.split(":")[1]
size: file.size
};
let encMetadata = "ENC:" + await encryptAesGcmToBase64(JSON.stringify(metadataObj), dmKeyBytes);
attachmentsPayload.push({
id: i,
staging_id: decText.split(":")[1],
enc: encMetadata
});
} else {
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
@ -3671,9 +3755,11 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
if (window.currentRoomType === 'groups') {
msgPayload.string5 = window.currentGroupKeyIndex || "0";
msgPayload.rawPlainContent = content; // for retry
}
let savedContentForRestore = content;
let savedAttachmentsForRestore = [...currentDraftAttachments];
input.value = "";
input.style.height = '2.5rem';
sendbutton.style.height = '2.5rem';
@ -3693,7 +3779,13 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
if (res && res.startsWith("error:key.rotation.required") && window.currentRoomType === 'groups') {
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
showRotationPopup(currentDmId, msgPayload);
// Restore input and attachments for the user to retry manually
input.value = savedContentForRestore;
currentDraftAttachments = savedAttachmentsForRestore;
renderAttachmentsPreview();
showRotationPopup(currentDmId, null);
} else if (res && res.startsWith("error:")) {
showBlahNotification(res);
let pElem = document.getElementById(pendingMsgId);
@ -4282,7 +4374,12 @@ async function changePassword() {
}
async function deleteAccount() {
if (!confirm(processBlah("desc.delete.account") + "\n\n" + processBlah("prompt.are.you.sure"))) return;
showConfirmPopup(
"title.delete.account",
"<blah>desc.delete.account</blah><br><br><blah>prompt.are.you.sure</blah>",
"action.delete.account",
"action.cancel",
async () => {
showAction("action.account.deleting", "accountdel");
let res = await fetchPostEnc(`${url}/deleteaccount?id=${window.id}`, "");
clearAction("accountdel");
@ -4293,6 +4390,10 @@ async function deleteAccount() {
} else {
showBlahNotification(text);
}
},
null,
true
);
}
async function saveProfile() {
@ -4349,8 +4450,8 @@ function initCropper() {
let zoomSlider = document.getElementById('cropper-zoom');
// Set aspect ratio
let targetWidth = cropperType === 'avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? targetWidth : 200;
let aspectRatio = targetWidth / targetHeight;
container.style.height = 'auto';
@ -4418,8 +4519,8 @@ async function confirmCropper() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let container = document.getElementById('cropper-viewport-container');
let targetWidth = cropperType === 'avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' ? 512 : 400;
let targetWidth = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' || cropperType === 'group-avatar' ? 512 : 400;
let scaleRatio = targetWidth / container.clientWidth;
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
@ -4590,10 +4691,20 @@ async function confirmCropper() {
newSrc = 'data:image/webp;base64,' + b64;
}
let payloadKey = cropperType === 'avatar' ? 'larp.profile.pfp' : 'larp.profile.banner';
let fileBytes = base64ToUint8(b64);
let payloadKey = (cropperType === 'avatar' || cropperType === 'group-avatar') ? 'larp.profile.pfp' : 'larp.profile.banner';
showAction("action.uploading", "uploadmedia");
let res = await fetchEncryptedUpload(payloadKey, fileBytes);
let res;
if (cropperType === 'group-avatar') {
let payload = JSON.stringify({
string1: window.currentSettingsGroupId,
string2: payloadKey,
string3: b64
});
res = await fetchEncrypted("group/storage/public/update", payload);
} else {
let fileBytes = base64ToUint8(b64);
res = await fetchEncryptedUpload(payloadKey, fileBytes);
}
clearAction("uploadmedia");
if (res && res.startsWith("error:")) {
showBlahNotification(res);
@ -4603,9 +4714,15 @@ async function confirmCropper() {
// Update all matching images on the page instantly
document.querySelectorAll('img').forEach(img => {
if (cropperType === 'group-avatar') {
if (img.src.includes(`id=${window.currentSettingsGroupId}&e=${payloadKey}`) || img.id === 'group-settings-avatar-preview') {
img.src = newSrc;
}
} else {
if (img.src.includes(`id=${window.id}&e=${payloadKey}`) || img.id === `settings-${cropperType}-preview` || (cropperType === 'avatar' && (img.id === 'my-avatar-img' || img.id === 'sidebar-pfp'))) {
img.src = newSrc;
}
}
});
}
@ -4673,6 +4790,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
if (nameSpan) {
nameSpan.style.opacity = isRead ? "0.8" : "1";
}
let badge = btn.querySelector('.unread-badge');
if (badge) {
if (isRead) badge.classList.remove("show");
else badge.classList.add("show");
}
if (timestamp) {
container.prepend(btn);
}
@ -4705,6 +4827,11 @@ function updateDmListUI(roomId, isRead, type = 'dms', timestamp = null) {
if (nameSpan) {
nameSpan.style.opacity = isRead ? "0.8" : "1";
}
let badge = btn.querySelector('.unread-badge');
if (badge) {
if (isRead) badge.classList.remove("show");
else badge.classList.add("show");
}
if (timestamp) {
container.prepend(btn);
}
@ -4927,9 +5054,48 @@ function handleGroupContextMenu(e, groupId) {
fixedContextMenu.style.top = `${newTop}px`;
}
function gotoGroupSettings(groupId) {
async function gotoGroupSettings(groupId) {
if (fixedContextMenu) fixedContextMenu.classList.remove("show");
// Not implemented yet
window.currentSettingsGroupId = groupId;
let infoStr = await fetchEncrypted("group/info", groupId);
if (!infoStr.startsWith("error:")) {
let info = JSON.parse(infoStr);
await switchRoomContent("title.group.settings", groupSettingsScreen, false);
document.getElementById('group-settings-name').value = info.string2;
let pfpUrl = await getGroupAvatarUrl(groupId, info.string2);
document.getElementById('group-settings-avatar-preview').src = pfpUrl;
document.getElementById('group-settings-history').checked = (info.string6 === "true");
} else {
showBlahNotification(infoStr);
}
}
async function saveGroupSettings() {
let groupId = window.currentSettingsGroupId;
let name = document.getElementById('group-settings-name').value;
let historyVisible = document.getElementById('group-settings-history').checked.toString();
let payload = JSON.stringify({
string1: groupId,
string2: name,
string3: historyVisible
});
showAction("action.saving", "savegroup");
let res = await fetchEncrypted("group/settings/update", payload);
clearAction("savegroup");
if (res === "success") {
showBlahNotification("success:group.settings.saved");
refreshGroups(0, false);
} else {
showBlahNotification(res);
}
}
function leaveGroupPrompt(groupId) {

View file

@ -31,8 +31,18 @@ var createGroupScreen = `
<div class="input-group">
<label for="creategroup-name"><blah>title.name</blah></label>
<input type="text" id="creategroup-name" placeholder="{blah(placeholder.create.group.input)}" class="forminput">
<div class="setting-row" style="text-align: left;">
<div class="setting-info">
<span class="setting-title"><blah>title.group.history.visible</blah></span>
<span class="setting-desc"><blah>desc.group.history.visible</blah></span>
</div>
<div class="input-group">
<label class="toggle-switch">
<input type="checkbox" id="creategroup-history">
<span class="toggle-slider"></span>
</label>
</div>
<button class="submit-button blah" onclick="createGroup()">title.create.group</button>
</div>
</div>
@ -211,7 +221,7 @@ var profileContextMenu = `
<span class="blah">title.profile</span>
</button>
<button onclick="gotoSettings()">
${ICONS.settingsProfile()}
${ICONS.settings()}
<span class="blah">title.settings</span>
</button>
<button onclick="logout()">
@ -327,7 +337,7 @@ var accountSettings = `
<div class="input-group">
<label style="color: var(--big-red);"><blah>title.delete.account</blah></label>
<p style="font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.5rem; margin-left: 0.6rem;"><blah>desc.delete.account</blah></p>
<button class="submit-button blah" onclick="deleteAccount()" style="background: var(--big-red); color: white;">action.delete.account</button>
<button class="submit-button popup-btn-danger blah" onclick="deleteAccount()">action.delete.account</button>
</div>
</div>
`;
@ -368,6 +378,36 @@ var profileSettings = `
</div>
</div>
`;
var groupSettingsScreen = `
<div class="auth-box popup-auto-height" style="margin-top:2rem">
<div class="input-group" style="display: flex; flex-direction: row; align-items: stretch; justify-content: space-between; border: var(--border-width) solid var(--light-border-color); border-radius: 0.8rem; padding: 1rem; width: 100%; max-width: 30rem;">
<img id="group-settings-avatar-preview" src="" style="width: 4.5rem; height: 4.5rem; border-radius: 0.8rem; border: var(--border-width) solid rgba(255, 255, 255, 0.2); object-fit: cover; background: var(--main-bg-color);">
<div style="display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; padding: 0.1rem 0;">
<div style="font-weight: bold;"><blah>title.avatar</blah></div>
<button class="submit-button blah" style="margin: 0; padding: 0.5rem 1rem; width: auto;" onclick="document.getElementById('group-settings-avatar-input').click()">action.change.avatar</button>
<input type="file" id="group-settings-avatar-input" style="display: none;" accept="image/*" onchange="openCropper(this, 'group-avatar')">
</div>
</div>
<br>
<div class="input-group">
<label for="group-settings-name"><blah>title.group.name</blah></label>
<input type="text" id="group-settings-name" class="forminput">
<div class="setting-row">
<div class="setting-info">
<span class="setting-title"><blah>title.group.history.visible</blah></span>
<span class="setting-desc"><blah>desc.group.history.visible</blah></span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="group-settings-history">
<span class="toggle-slider"></span>
</label>
</div>
<button class="submit-button blah" onclick="saveGroupSettings()">action.save</button>
</div>
</div>
`;
var appearanceSettings = `
<div style="display: flex;justify-content: center;align-items: center;height:100%;flex-direction: column;text-align: center;">

View file

@ -366,6 +366,7 @@ herotitle {
gap: 0.2rem;
width: 90%;
max-width: 30rem;
margin: 0 auto;
}
.input-group label {
font-size: 0.75rem;
@ -878,35 +879,27 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
height: auto;
max-height: 85vh;
}
.popup-btn-cancel {
button.profile-action-btn.popup-action-btn {
flex: 1;
display: flex;
justify-content: center;
margin-top: 0;
}
button.profile-action-btn.popup-btn-cancel {
background: rgba(255, 255, 255, 0.05);
color: var(--text-color);
border: var(--border-width) solid var(--light-border-color);
flex: 1;
justify-content: center;
}
.popup-btn-cancel:hover {
button.profile-action-btn.popup-btn-cancel:hover {
background: rgba(255, 255, 255, 0.1);
}
.popup-btn-confirm {
background: var(--text-color);
color: var(--main-bg-color);
button.profile-action-btn.popup-btn-danger, button.submit-button.popup-btn-danger {
background: var(--big-red) !important;
color: white !important;
border: none;
flex: 1;
justify-content: center;
}
.popup-btn-confirm:hover {
background: white;
}
.popup-btn-danger {
background: var(--big-red);
color: white;
border: none;
flex: 1;
justify-content: center;
}
.popup-btn-danger:hover {
background: #ff5252;
button.profile-action-btn.popup-btn-danger:hover, button.submit-button.popup-btn-danger:hover {
filter: brightness(0.85);
}
.profile-popup-container.show .profile-popup-content {
transform: scale(1);
@ -1072,3 +1065,106 @@ body.hide-topbar-pin #roomdetailsbar roomtopbar .room-action-pin { display: flex
body.hide-topbar-call roomcontent roomtopbar .room-action-call { display: none !important; }
body.hide-topbar-call #roomdetailsbar roomtopbar .room-action-call { display: flex !important; }
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 3.4rem;
height: 1.8rem;
margin: var(--button-margin);
flex-shrink: 0;
}
label.toggle-switch {
opacity: 0.8;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.1);
transition: .4s;
border-radius: 1.8rem;
border: var(--border-width) solid var(--light-border-color);
}
.toggle-slider:before {
position: absolute;
content: "";
height: 1.2rem;
width: 1.2rem;
left: 0.25rem;
top: 50%;
transform: translateY(-50%);
background-color: var(--text-color);
transition: .4s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: var(--text-color) !important;
border-color: var(--text-color) !important;
}
input:checked + .toggle-slider:before {
transform: translate(1.6rem, -50%);
background-color: var(--main-bg-color) !important;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
width: auto;
box-sizing: border-box;
margin: var(--button-margin);
padding: 0.5rem;
border-radius: 0.8rem;
background-color: rgba(255, 255, 255, 0.02);
}
.setting-row:hover {
background-color: rgba(255, 255, 255, 0.04);
}
.setting-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.setting-title {
font-size: 1.1rem;
font-weight: bold;
}
.setting-desc {
font-size: 0.85rem;
color: var(--darker-text-color);
margin-top: 0.2rem;
}
.unread-badge {
width: 0.65rem;
height: 0.65rem;
min-width: 0.65rem;
min-height: 0.65rem;
background-color: var(--big-red);
border-radius: 50%;
flex-shrink: 0;
margin-right: 0.5rem;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: scale(0);
}
.unread-badge.show {
transform: scale(1);
animation: badge-appear 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes badge-appear {
0% { transform: scale(0); }
100% { transform: scale(1); }
}