add attachments
All checks were successful
Android Build / publish (push) Successful in 31s
Linux Build / publish (push) Successful in 1m0s

This commit is contained in:
olcxja 2026-07-14 19:26:26 +02:00
commit 1800b89a6b
8 changed files with 804 additions and 28 deletions

View file

@ -184,5 +184,12 @@
"success:banner.updated": "Cat banner updated successfully :3",
"action.save": "Save :3",
"action.delete.account": "Delete cat",
"account.deleted.scheduled": "Cat scheduled for deletion. It will go to cat heaven after a month of inactivity. Pawing in again will cancel the deletion process :3"
"account.deleted.scheduled": "Cat scheduled for deletion. It will go to cat heaven after a month of inactivity. Pawing in again will cancel the deletion process :3",
"action.attachment.take.photo": "Meow photo",
"action.attachment.media": "Meowdia",
"action.attachment.file": "Meowle",
"action.attachment.poll": "Meowll",
"action.attachment.uploading": "Uploading meowttachments...",
"attachment.legacy": "Legacy meowttachment",
"warning.unencrypted": "This meowsage is not encrypted"
}

View file

@ -184,5 +184,12 @@
"banner.updated": "Banner updated successfully",
"action.save": "Save",
"action.delete.account": "Delete account",
"account.deleted.scheduled": "Account scheduled for deletion. It will be permanently deleted after a month of inactivity. Logging in during this time will cancel the deletion process."
"account.deleted.scheduled": "Account scheduled for deletion. It will be permanently deleted after a month of inactivity. Logging in during this time will cancel the deletion process.",
"action.attachment.take.photo": "Take photo",
"action.attachment.media": "Media",
"action.attachment.file": "File",
"action.attachment.poll": "Poll",
"action.attachment.uploading": "Uploading attachments...",
"attachment.legacy": "Legacy attachment",
"warning.unencrypted": "This message is not encrypted"
}

View file

@ -243,6 +243,24 @@ async function decryptAesGcmFromBase64(cipherBase64, keyBytes) {
return new TextDecoder().decode(ptBuf);
}
async function encryptAesGcmToBytes(plainBytes, keyBytes) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await importAesGcmKey(keyBytes);
const ctBuf = await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plainBytes);
const combined = new Uint8Array(iv.length + ctBuf.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ctBuf), iv.length);
return combined;
}
async function decryptAesGcmFromBytes(cipherBytes, keyBytes) {
const iv = cipherBytes.slice(0, 12);
const ct = cipherBytes.slice(12);
const key = await importAesGcmKey(keyBytes);
const ptBuf = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ct);
return new Uint8Array(ptBuf);
}
function randomRoomKey256Chars() {
const bytes = crypto.getRandomValues(new Uint8Array(192));
return uint8ToBase64(bytes);
@ -1424,6 +1442,24 @@ function clickAddDm() {
switchRoomContent("title.create.dm", addDmScreen, false);
}
function clickAddAttachment(btn, e) {
if (e) {
e.stopPropagation();
}
history.pushState({trap: true}, "", location.href);
if (fixedContextMenu.classList.contains("show")) {
fixedContextMenu.classList.remove("show");
} else {
const rect = btn.getBoundingClientRect();
showFixedContextMenu(rect, attachmentMenu);
// Let's reposition it so it appears properly anchored above the input if it's too close to the bottom.
// showFixedContextMenu automatically handles bounds, but it's set to top-right alignment basically.
// It's perfectly fine to rely on the default bounds checking of showFixedContextMenu.
}
}
var currentMainIndicator = null;
sidebarAddButton.addEventListener("click", (e) => {
@ -2285,6 +2321,31 @@ async function loadDmMessages(dmId, startOffset = "", isPrepend = false) {
}
}
function formatBytes(bytes, decimals = 2) {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
function truncateFilename(name, maxLength = 20) {
if (name.length <= maxLength) return name;
let extIndex = name.lastIndexOf(".");
if (extIndex === -1 || extIndex === 0) {
return name.substring(0, Math.floor(maxLength / 2) - 1) + "..." + name.substring(name.length - Math.floor(maxLength / 2) + 2);
}
let ext = name.substring(extIndex);
let baseName = name.substring(0, extIndex);
let allowedBaseLength = maxLength - ext.length - 3; // 3 for "..."
if (allowedBaseLength <= 1) {
return name.substring(0, maxLength - 3) + "...";
}
let half = Math.floor(allowedBaseLength / 2);
return baseName.substring(0, half) + "..." + baseName.substring(baseName.length - (allowedBaseLength - half)) + ext;
}
async function renderMessages(messages, isPrepend = false) {
let container = document.getElementById("chat-messages");
if (!container) return;
@ -2312,6 +2373,46 @@ async function renderMessages(messages, isPrepend = false) {
decrypted = true;
}
let isRedacted = msg.type === "larp.redacted";
let redactedStyle = isRedacted ? ` opacity: 0.5; font-style: italic;` : ``;
let redactedIcon = isRedacted ? `<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 -960 960 960" fill="currentColor" style="vertical-align: -0.165em; margin-right: 0.2rem;"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>` : ``;
let attachmentsHtml = "";
if (msg.attachment && msg.attachment !== "") {
if (msg.attachment.startsWith("[")) {
try {
let atts = JSON.parse(msg.attachment);
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) {
let attContainerId = `att-container-${msgId}-${att.id}`;
let isMedia = att.type.startsWith("image/") || att.type.startsWith("video/");
let sizeStr = att.size ? formatBytes(att.size) : "";
let displayName = truncateFilename(att.name, 25);
let iconSvg = isMedia
? `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>`;
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="downloadAndRenderAttachment('${msgId}', '${att.id}', '${escapeHtml(att.type)}', '${escapeHtml(att.name)}')">
<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>
${sizeStr ? `<div style="font-size: 0.7rem; opacity: 0.6; margin-top: 0.2rem;">${escapeHtml(sizeStr)}</div>` : ""}
</div>
</div>`;
}
attachmentsHtml += `</div>`;
}
} catch (e) {
console.error("Failed to parse attachments", e);
}
} else {
attachmentsHtml += `<div class="chat-message-attachments" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
<div style="padding: 0.5rem; background: rgba(0,0,0,0.2); border: var(--border-width) solid var(--border-color); border-radius: 0.5rem; font-size: 0.8rem; opacity: 0.7;">[${processBlah("attachment.legacy")}: ${escapeHtml(msg.attachment)}]</div>
</div>`;
}
}
if (decrypted) {
content = escapeHtml(content);
}
@ -2353,6 +2454,8 @@ async function renderMessages(messages, isPrepend = false) {
let extraClass = showAvatar ? "with-avatar" : "";
let unencryptedIcon = (!decrypted && !isRedacted) ? `<span title="${processBlah('warning.unencrypted')}" style="color: var(--big-red); vertical-align: -0.165em; margin-right: 0.2rem; cursor: help;"><svg xmlns="http://www.w3.org/2000/svg" width="1rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 9.9-1"></path></svg></span>` : ``;
let repliedHtml = "";
if (msg.responded && msg.responded !== "") {
let respondedId = msg.responded;
@ -2465,10 +2568,6 @@ async function renderMessages(messages, isPrepend = false) {
}
}
let isRedacted = msg.type === "larp.redacted";
let redactedStyle = isRedacted ? ` opacity: 0.5; font-style: italic;` : ``;
let redactedIcon = isRedacted ? `<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 -960 960 960" fill="currentColor" style="vertical-align: -0.165em; margin-right: 0.2rem;"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>` : ``;
html += `
<div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
@ -2478,7 +2577,8 @@ async function renderMessages(messages, isPrepend = false) {
<span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""}
${repliedHtml}
<div class="chat-message-text" style="${redactedStyle}">${redactedIcon}${content}</div>
<div class="chat-message-text" style="${redactedStyle}">${unencryptedIcon}${redactedIcon}${content}</div>
${attachmentsHtml}
${reactionsHtml}
</div>
</div>
@ -2495,7 +2595,76 @@ async function renderMessages(messages, isPrepend = false) {
let oldScrollTop = container.scrollTop;
let oldScrollHeight = container.scrollHeight;
container.innerHTML = html;
let tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
let newChildren = Array.from(tempDiv.children);
let currentIndex = 0;
for (let newChild of newChildren) {
let existingChild = document.getElementById(newChild.id);
if (existingChild && existingChild.parentElement === container) {
if (container.children[currentIndex] !== existingChild) {
container.insertBefore(existingChild, container.children[currentIndex]);
}
let oldText = existingChild.querySelector('.chat-message-text');
let newText = newChild.querySelector('.chat-message-text');
if (oldText && newText && oldText.innerHTML !== newText.innerHTML) {
oldText.innerHTML = newText.innerHTML;
}
let oldReactions = existingChild.querySelector('.chat-message-reactions');
let newReactions = newChild.querySelector('.chat-message-reactions');
if (newReactions) {
if (oldReactions) {
if (oldReactions.innerHTML !== newReactions.innerHTML) oldReactions.innerHTML = newReactions.innerHTML;
} else {
existingChild.querySelector('.chat-message-content').appendChild(newReactions);
}
} else if (oldReactions) {
oldReactions.remove();
}
let oldPfp = existingChild.querySelector('.chat-message-pfp');
let newPfp = newChild.querySelector('.chat-message-pfp');
if (oldPfp && newPfp && oldPfp.src !== newPfp.src) {
oldPfp.src = newPfp.src;
}
let oldHeader = existingChild.querySelector('.chat-message-header');
let newHeader = newChild.querySelector('.chat-message-header');
if (oldHeader && newHeader && oldHeader.innerHTML !== newHeader.innerHTML) {
oldHeader.innerHTML = newHeader.innerHTML;
}
let oldReplied = existingChild.querySelector('.chat-message-replied');
let newReplied = newChild.querySelector('.chat-message-replied');
if (newReplied) {
if (oldReplied) {
if (oldReplied.innerHTML !== newReplied.innerHTML) oldReplied.innerHTML = newReplied.innerHTML;
} else {
let contentNode = existingChild.querySelector('.chat-message-content');
if (oldText) contentNode.insertBefore(newReplied, oldText);
else contentNode.insertBefore(newReplied, contentNode.firstChild);
}
} else if (oldReplied) {
oldReplied.remove();
}
} else {
if (container.children[currentIndex]) {
container.insertBefore(newChild, container.children[currentIndex]);
} else {
container.appendChild(newChild);
}
}
currentIndex++;
}
while (container.children.length > newChildren.length) {
container.removeChild(container.lastChild);
}
let targetScrollTop = 0;
if (wasAtBottom) {
@ -2569,14 +2738,127 @@ function setupChatScrollListener() {
async function downloadAndRenderAttachment(msgId, attId, type, name) {
if (!currentDmId || !currentDmKey) return;
let container = document.getElementById(`att-container-${msgId}-${attId}`);
if (!container) return;
if (container.dataset.loaded === "true") {
if (!type.startsWith("image/") && !type.startsWith("video/")) {
let a = document.createElement("a");
a.href = container.dataset.url;
a.download = name;
a.click();
}
return;
}
if (container.dataset.loading === "true") return;
container.dataset.loading = "true";
let originalHtml = container.innerHTML;
container.innerHTML = `<div style="padding: 1rem; color: var(--text-color);"><svg class="spinner" viewBox="0 0 50 50" style="width:24px;height:24px;"><circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle></svg></div>`;
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let nonce = await getNonce(id, passwordHash);
let res = await fetch(`${url}/dm/attachment/get?id=${id}&dm=${currentDmId}&msg=${msgId}&att=${attId}`, {
headers: {
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
}
});
if (!res.ok) throw new Error("Failed to fetch attachment");
let encryptedBytes = new Uint8Array(await res.arrayBuffer());
let decryptedBytes = await decryptAesGcmFromBytes(encryptedBytes, dmKeyBytes);
let blob = new Blob([decryptedBytes], {type: type});
let blobUrl = URL.createObjectURL(blob);
container.dataset.loaded = "true";
container.dataset.url = blobUrl;
if (type.startsWith("image/")) {
container.innerHTML = `<img src="${blobUrl}" style="max-width: 100%; max-height: 300px; display: block;" onclick="window.open('${blobUrl}', '_blank')">`;
} else if (type.startsWith("video/")) {
container.innerHTML = `<video src="${blobUrl}" controls style="max-width: 100%; max-height: 300px; display: block;"></video>`;
} else {
container.innerHTML = originalHtml;
container.style.border = "1px solid var(--accent-color)";
let a = document.createElement("a");
a.href = blobUrl;
a.download = name;
a.click();
}
} catch (e) {
console.error(e);
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading</div>`;
container.dataset.loading = "false";
}
}
let currentDraftAttachments = [];
let replyingToMsgId = null;
function handleAttachmentSelection(event) {
if (!event.target.files) return;
for (let file of event.target.files) {
currentDraftAttachments.push(file);
}
event.target.value = '';
renderAttachmentsPreview();
}
function removeDraftAttachment(index) {
currentDraftAttachments.splice(index, 1);
renderAttachmentsPreview();
}
function renderAttachmentsPreview() {
let previewDiv = document.getElementById("attachments-preview");
if (!previewDiv) return;
if (currentDraftAttachments.length === 0) {
previewDiv.style.display = "none";
previewDiv.innerHTML = "";
return;
}
previewDiv.style.display = "flex";
let html = "";
currentDraftAttachments.forEach((file, index) => {
let isImage = file.type.startsWith("image/");
let isVideo = file.type.startsWith("video/");
let iconHtml = "";
if (isImage) {
iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
} else if (isVideo) {
iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>`;
} else {
iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`;
}
html += `
<div style="display: flex; align-items: center; gap: 0.8rem; background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: var(--border-width) solid rgba(255, 255, 255, 0.1); padding: 0.5rem 0.5rem 0.5rem 1rem; border-radius: 0.75rem; flex-shrink: 0; max-width: 15.625rem; box-shadow: 0 0.25rem 0.375rem rgba(0,0,0,0.1); transition: transform 0.2s ease;">
<div style="flex-shrink: 0; color: var(--accent-color); display: flex; align-items: center;">${iconHtml}</div>
<div style="font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-color); font-weight: 500; flex-grow: 1;">${escapeHtml(file.name)}</div>
<button onclick="removeDraftAttachment(${index})" style="background: transparent; color: var(--text-color); border: none; width: 1.5rem; height: 1.5rem; flex-shrink: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0.125rem; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.6'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" style="width:100%; height:100%;"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>
</button>
</div>`;
});
previewDiv.innerHTML = html;
}
async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) {
if (!currentDmId || !currentDmKey) return;
let input = document.getElementById("chat-input");
let content = input.value.trim();
if (!content) return;
if (!content && currentDraftAttachments.length === 0) return;
let pendingMsgId = "pending_" + Date.now();
let container = document.getElementById("chat-messages");
@ -2597,16 +2879,66 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes);
let attachmentsPayload = [];
if (currentDraftAttachments.length > 0) {
showAction(processBlah("action.attachment.uploading"), "uploading_atts");
for (let i = 0; i < currentDraftAttachments.length; i++) {
let file = currentDraftAttachments[i];
let arrayBuffer = await file.arrayBuffer();
let encryptedFileBytes = await encryptAesGcmToBytes(new Uint8Array(arrayBuffer), dmKeyBytes);
let nonce = await getNonce(id, passwordHash);
let res;
try {
res = await fetch(`${url}/dm/attachment/staging?id=${id}`, {
method: "POST",
headers: {
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
},
body: encryptedFileBytes
});
} catch (e) {
throw new Error("error.network.error");
}
if (res.status === 413) {
throw new Error("error.body.too.large");
}
let text = await res.text();
let decText;
try {
decText = await decryptString(text, passwordHash);
} catch (e) {
throw new Error("error:message.send.failed"); // trigger proper blah notification
}
if (decText && decText.startsWith("success:")) {
attachmentsPayload.push({
id: i,
type: file.type,
name: file.name,
size: file.size,
staging_id: decText.split(":")[1]
});
} else {
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
}
}
clearAction("uploading_atts");
}
let msgPayload = {
string1: currentDmId,
string2: encryptedContent,
string3: "",
string3: attachmentsPayload.length > 0 ? JSON.stringify(attachmentsPayload) : "",
string4: replyingToMsgId || ""
};
input.value = "";
input.style.height = '2.5rem';
sendbutton.style.height = '2.5rem';
currentDraftAttachments = [];
renderAttachmentsPreview();
window.forceScrollToBottom = true;
replyingToMsgId = null;
@ -2625,12 +2957,20 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
let parts = res.split(":");
if (parts.length > 1 && parts[1] !== "message.sent") {
let newMsgId = parts[1];
let finalAttPayload = "";
if (attachmentsPayload.length > 0) {
finalAttPayload = JSON.stringify(attachmentsPayload.map(a => {
let clone = Object.assign({}, a);
delete clone.staging_id;
return clone;
}));
}
loadedMessages[newMsgId] = {
author: id,
timestamp: Date.now().toString(),
type: "larp.text",
content: encryptedContent,
attachment: "",
attachment: finalAttPayload,
key: "0",
pervious: "",
responded: replyingToMsgId || "",
@ -2640,9 +2980,14 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
}
}
} catch (e) {
clearAction("uploading_atts");
clearAction("msgsending");
console.error(e);
if (e.message && e.message.startsWith("error:")) {
showBlahNotification(e.message);
} else {
showBlahNotification("error:message.send.failed");
}
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
}

View file

@ -141,9 +141,16 @@ var chatScreen = `
<svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" viewBox="0 -960 960 960" fill="currentColor"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>
</button>
</div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem;">
<div id="attachments-preview" style="display: none; padding: 0.5rem 1rem; gap: 0.8rem; overflow-x: auto; border-top: var(--border-width) solid var(--light-border-color); background: var(--main-bg-color); align-items: center; flex-shrink: 0;"></div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem; align-items: flex-end; flex-shrink: 0;">
<button id="chat-add-attachment" class="submit-button" onclick="clickAddAttachment(this, event)" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" viewBox="0 0 24 24" fill="none" stroke="var(--main-bg-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<textarea id="chat-input" class="forminput" style="flex-grow: 1; margin: 0; resize: none; min-height: 2.5rem; max-height: 8rem; padding: 0.5rem;" placeholder="{blah(placeholder.message.input)}" onkeydown="handleChatInputKey(event)" oninput="handleChatInputResize(this)"></textarea>
<button id="chat-sendmessage" class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; min-height: 2.5rem; max-height: 8rem;">
<button id="chat-sendmessage" class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" viewBox="0 0 16 16" fill="none">
<path stroke="var(--main-bg-color)" stroke-linecap="round" stroke-linejoin="round" d="M 4.65 8 l -1.875 5.625 l 11.25 -5.625 L 2.775 2.375 l 1.875 5.625 z m 0 0 h 3.75" stroke-width="1.1"></path>
</svg>
@ -231,6 +238,28 @@ var profileContextMenu = `
<span class="blah" style="color: var(--big-red)">title.logout</span>
</button>
`;
var attachmentMenu = `
<button onclick="document.getElementById('attach-take-photo').click(); fixedContextMenu.classList.remove('show');">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 0 24 24" fill="none" stroke="var(--text-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></svg>
<span class="blah">action.attachment.take.photo</span>
</button>
<button onclick="document.getElementById('attach-media').click(); fixedContextMenu.classList.remove('show');">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 0 24 24" fill="none" stroke="var(--text-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
<span class="blah">action.attachment.media</span>
</button>
<button onclick="document.getElementById('attach-file').click(); fixedContextMenu.classList.remove('show');">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 0 24 24" fill="none" stroke="var(--text-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
<span class="blah">action.attachment.file</span>
</button>
<button onclick="console.log('poll'); fixedContextMenu.classList.remove('show');">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 0 24 24" fill="none" stroke="var(--text-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
<span class="blah">action.attachment.poll</span>
</button>
<input type="file" id="attach-take-photo" accept="image/*" capture="environment" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-media" accept="image/*,video/*" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-file" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
`;
var addSpaceMenu = `
<button onclick="gotoJoinSpace()">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M440-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h160v80H280q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h320v80H320Zm200 160v-80h160q50 0 85-35t35-85q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H520Z"/></svg>

View file

@ -184,5 +184,12 @@
"success:banner.updated": "Cat banner updated successfully :3",
"action.save": "Save :3",
"action.delete.account": "Delete cat",
"account.deleted.scheduled": "Cat scheduled for deletion. It will go to cat heaven after a month of inactivity. Pawing in again will cancel the deletion process :3"
"account.deleted.scheduled": "Cat scheduled for deletion. It will go to cat heaven after a month of inactivity. Pawing in again will cancel the deletion process :3",
"action.attachment.take.photo": "Meow photo",
"action.attachment.media": "Meowdia",
"action.attachment.file": "Meowle",
"action.attachment.poll": "Meowll",
"action.attachment.uploading": "Uploading meowttachments...",
"attachment.legacy": "Legacy meowttachment",
"warning.unencrypted": "This meowsage is not encrypted"
}

View file

@ -184,5 +184,12 @@
"banner.updated": "Banner updated successfully",
"action.save": "Save",
"action.delete.account": "Delete account",
"account.deleted.scheduled": "Account scheduled for deletion. It will be permanently deleted after a month of inactivity. Logging in during this time will cancel the deletion process."
"account.deleted.scheduled": "Account scheduled for deletion. It will be permanently deleted after a month of inactivity. Logging in during this time will cancel the deletion process.",
"action.attachment.take.photo": "Take photo",
"action.attachment.media": "Media",
"action.attachment.file": "File",
"action.attachment.poll": "Poll",
"action.attachment.uploading": "Uploading attachments...",
"attachment.legacy": "Legacy attachment",
"warning.unencrypted": "This message is not encrypted"
}

View file

@ -243,6 +243,24 @@ async function decryptAesGcmFromBase64(cipherBase64, keyBytes) {
return new TextDecoder().decode(ptBuf);
}
async function encryptAesGcmToBytes(plainBytes, keyBytes) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await importAesGcmKey(keyBytes);
const ctBuf = await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plainBytes);
const combined = new Uint8Array(iv.length + ctBuf.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ctBuf), iv.length);
return combined;
}
async function decryptAesGcmFromBytes(cipherBytes, keyBytes) {
const iv = cipherBytes.slice(0, 12);
const ct = cipherBytes.slice(12);
const key = await importAesGcmKey(keyBytes);
const ptBuf = await crypto.subtle.decrypt({name: "AES-GCM", iv}, key, ct);
return new Uint8Array(ptBuf);
}
function randomRoomKey256Chars() {
const bytes = crypto.getRandomValues(new Uint8Array(192));
return uint8ToBase64(bytes);
@ -1424,6 +1442,24 @@ function clickAddDm() {
switchRoomContent("title.create.dm", addDmScreen, false);
}
function clickAddAttachment(btn, e) {
if (e) {
e.stopPropagation();
}
history.pushState({trap: true}, "", location.href);
if (fixedContextMenu.classList.contains("show")) {
fixedContextMenu.classList.remove("show");
} else {
const rect = btn.getBoundingClientRect();
showFixedContextMenu(rect, attachmentMenu);
// Let's reposition it so it appears properly anchored above the input if it's too close to the bottom.
// showFixedContextMenu automatically handles bounds, but it's set to top-right alignment basically.
// It's perfectly fine to rely on the default bounds checking of showFixedContextMenu.
}
}
var currentMainIndicator = null;
sidebarAddButton.addEventListener("click", (e) => {
@ -2285,6 +2321,31 @@ async function loadDmMessages(dmId, startOffset = "", isPrepend = false) {
}
}
function formatBytes(bytes, decimals = 2) {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
function truncateFilename(name, maxLength = 20) {
if (name.length <= maxLength) return name;
let extIndex = name.lastIndexOf(".");
if (extIndex === -1 || extIndex === 0) {
return name.substring(0, Math.floor(maxLength / 2) - 1) + "..." + name.substring(name.length - Math.floor(maxLength / 2) + 2);
}
let ext = name.substring(extIndex);
let baseName = name.substring(0, extIndex);
let allowedBaseLength = maxLength - ext.length - 3; // 3 for "..."
if (allowedBaseLength <= 1) {
return name.substring(0, maxLength - 3) + "...";
}
let half = Math.floor(allowedBaseLength / 2);
return baseName.substring(0, half) + "..." + baseName.substring(baseName.length - (allowedBaseLength - half)) + ext;
}
async function renderMessages(messages, isPrepend = false) {
let container = document.getElementById("chat-messages");
if (!container) return;
@ -2312,6 +2373,46 @@ async function renderMessages(messages, isPrepend = false) {
decrypted = true;
}
let isRedacted = msg.type === "larp.redacted";
let redactedStyle = isRedacted ? ` opacity: 0.5; font-style: italic;` : ``;
let redactedIcon = isRedacted ? `<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 -960 960 960" fill="currentColor" style="vertical-align: -0.165em; margin-right: 0.2rem;"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>` : ``;
let attachmentsHtml = "";
if (msg.attachment && msg.attachment !== "") {
if (msg.attachment.startsWith("[")) {
try {
let atts = JSON.parse(msg.attachment);
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) {
let attContainerId = `att-container-${msgId}-${att.id}`;
let isMedia = att.type.startsWith("image/") || att.type.startsWith("video/");
let sizeStr = att.size ? formatBytes(att.size) : "";
let displayName = truncateFilename(att.name, 25);
let iconSvg = isMedia
? `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>`;
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="downloadAndRenderAttachment('${msgId}', '${att.id}', '${escapeHtml(att.type)}', '${escapeHtml(att.name)}')">
<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>
${sizeStr ? `<div style="font-size: 0.7rem; opacity: 0.6; margin-top: 0.2rem;">${escapeHtml(sizeStr)}</div>` : ""}
</div>
</div>`;
}
attachmentsHtml += `</div>`;
}
} catch (e) {
console.error("Failed to parse attachments", e);
}
} else {
attachmentsHtml += `<div class="chat-message-attachments" style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
<div style="padding: 0.5rem; background: rgba(0,0,0,0.2); border: var(--border-width) solid var(--border-color); border-radius: 0.5rem; font-size: 0.8rem; opacity: 0.7;">[${processBlah("attachment.legacy")}: ${escapeHtml(msg.attachment)}]</div>
</div>`;
}
}
if (decrypted) {
content = escapeHtml(content);
}
@ -2353,6 +2454,8 @@ async function renderMessages(messages, isPrepend = false) {
let extraClass = showAvatar ? "with-avatar" : "";
let unencryptedIcon = (!decrypted && !isRedacted) ? `<span title="${processBlah('warning.unencrypted')}" style="color: var(--big-red); vertical-align: -0.165em; margin-right: 0.2rem; cursor: help;"><svg xmlns="http://www.w3.org/2000/svg" width="1rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 9.9-1"></path></svg></span>` : ``;
let repliedHtml = "";
if (msg.responded && msg.responded !== "") {
let respondedId = msg.responded;
@ -2465,10 +2568,6 @@ async function renderMessages(messages, isPrepend = false) {
}
}
let isRedacted = msg.type === "larp.redacted";
let redactedStyle = isRedacted ? ` opacity: 0.5; font-style: italic;` : ``;
let redactedIcon = isRedacted ? `<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 -960 960 960" fill="currentColor" style="vertical-align: -0.165em; margin-right: 0.2rem;"><path d="M280-120q-33 0-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0 33-23.5 56.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160 0h80v-360h-80v360ZM280-720v520-520Z"/></svg>` : ``;
html += `
<div class="chat-message ${extraClass}" data-msg-id="${msgId}" id="msg-${msgId}">
${showAvatar ? `<img src="${pfp}" class="chat-message-pfp" onclick="openProfile('${msg.author}')" style="cursor: pointer;">` : `<div style="width: 2.5rem; flex-shrink: 0;"></div>`}
@ -2478,7 +2577,8 @@ async function renderMessages(messages, isPrepend = false) {
<span class="chat-message-timestamp">${timeStr}</span>
</div>` : ""}
${repliedHtml}
<div class="chat-message-text" style="${redactedStyle}">${redactedIcon}${content}</div>
<div class="chat-message-text" style="${redactedStyle}">${unencryptedIcon}${redactedIcon}${content}</div>
${attachmentsHtml}
${reactionsHtml}
</div>
</div>
@ -2495,7 +2595,76 @@ async function renderMessages(messages, isPrepend = false) {
let oldScrollTop = container.scrollTop;
let oldScrollHeight = container.scrollHeight;
container.innerHTML = html;
let tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
let newChildren = Array.from(tempDiv.children);
let currentIndex = 0;
for (let newChild of newChildren) {
let existingChild = document.getElementById(newChild.id);
if (existingChild && existingChild.parentElement === container) {
if (container.children[currentIndex] !== existingChild) {
container.insertBefore(existingChild, container.children[currentIndex]);
}
let oldText = existingChild.querySelector('.chat-message-text');
let newText = newChild.querySelector('.chat-message-text');
if (oldText && newText && oldText.innerHTML !== newText.innerHTML) {
oldText.innerHTML = newText.innerHTML;
}
let oldReactions = existingChild.querySelector('.chat-message-reactions');
let newReactions = newChild.querySelector('.chat-message-reactions');
if (newReactions) {
if (oldReactions) {
if (oldReactions.innerHTML !== newReactions.innerHTML) oldReactions.innerHTML = newReactions.innerHTML;
} else {
existingChild.querySelector('.chat-message-content').appendChild(newReactions);
}
} else if (oldReactions) {
oldReactions.remove();
}
let oldPfp = existingChild.querySelector('.chat-message-pfp');
let newPfp = newChild.querySelector('.chat-message-pfp');
if (oldPfp && newPfp && oldPfp.src !== newPfp.src) {
oldPfp.src = newPfp.src;
}
let oldHeader = existingChild.querySelector('.chat-message-header');
let newHeader = newChild.querySelector('.chat-message-header');
if (oldHeader && newHeader && oldHeader.innerHTML !== newHeader.innerHTML) {
oldHeader.innerHTML = newHeader.innerHTML;
}
let oldReplied = existingChild.querySelector('.chat-message-replied');
let newReplied = newChild.querySelector('.chat-message-replied');
if (newReplied) {
if (oldReplied) {
if (oldReplied.innerHTML !== newReplied.innerHTML) oldReplied.innerHTML = newReplied.innerHTML;
} else {
let contentNode = existingChild.querySelector('.chat-message-content');
if (oldText) contentNode.insertBefore(newReplied, oldText);
else contentNode.insertBefore(newReplied, contentNode.firstChild);
}
} else if (oldReplied) {
oldReplied.remove();
}
} else {
if (container.children[currentIndex]) {
container.insertBefore(newChild, container.children[currentIndex]);
} else {
container.appendChild(newChild);
}
}
currentIndex++;
}
while (container.children.length > newChildren.length) {
container.removeChild(container.lastChild);
}
let targetScrollTop = 0;
if (wasAtBottom) {
@ -2569,14 +2738,127 @@ function setupChatScrollListener() {
async function downloadAndRenderAttachment(msgId, attId, type, name) {
if (!currentDmId || !currentDmKey) return;
let container = document.getElementById(`att-container-${msgId}-${attId}`);
if (!container) return;
if (container.dataset.loaded === "true") {
if (!type.startsWith("image/") && !type.startsWith("video/")) {
let a = document.createElement("a");
a.href = container.dataset.url;
a.download = name;
a.click();
}
return;
}
if (container.dataset.loading === "true") return;
container.dataset.loading = "true";
let originalHtml = container.innerHTML;
container.innerHTML = `<div style="padding: 1rem; color: var(--text-color);"><svg class="spinner" viewBox="0 0 50 50" style="width:24px;height:24px;"><circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle></svg></div>`;
try {
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let nonce = await getNonce(id, passwordHash);
let res = await fetch(`${url}/dm/attachment/get?id=${id}&dm=${currentDmId}&msg=${msgId}&att=${attId}`, {
headers: {
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
}
});
if (!res.ok) throw new Error("Failed to fetch attachment");
let encryptedBytes = new Uint8Array(await res.arrayBuffer());
let decryptedBytes = await decryptAesGcmFromBytes(encryptedBytes, dmKeyBytes);
let blob = new Blob([decryptedBytes], {type: type});
let blobUrl = URL.createObjectURL(blob);
container.dataset.loaded = "true";
container.dataset.url = blobUrl;
if (type.startsWith("image/")) {
container.innerHTML = `<img src="${blobUrl}" style="max-width: 100%; max-height: 300px; display: block;" onclick="window.open('${blobUrl}', '_blank')">`;
} else if (type.startsWith("video/")) {
container.innerHTML = `<video src="${blobUrl}" controls style="max-width: 100%; max-height: 300px; display: block;"></video>`;
} else {
container.innerHTML = originalHtml;
container.style.border = "1px solid var(--accent-color)";
let a = document.createElement("a");
a.href = blobUrl;
a.download = name;
a.click();
}
} catch (e) {
console.error(e);
container.innerHTML = `<div style="padding: 1rem; color: var(--big-red); font-size: 0.8rem;">Error loading</div>`;
container.dataset.loading = "false";
}
}
let currentDraftAttachments = [];
let replyingToMsgId = null;
function handleAttachmentSelection(event) {
if (!event.target.files) return;
for (let file of event.target.files) {
currentDraftAttachments.push(file);
}
event.target.value = '';
renderAttachmentsPreview();
}
function removeDraftAttachment(index) {
currentDraftAttachments.splice(index, 1);
renderAttachmentsPreview();
}
function renderAttachmentsPreview() {
let previewDiv = document.getElementById("attachments-preview");
if (!previewDiv) return;
if (currentDraftAttachments.length === 0) {
previewDiv.style.display = "none";
previewDiv.innerHTML = "";
return;
}
previewDiv.style.display = "flex";
let html = "";
currentDraftAttachments.forEach((file, index) => {
let isImage = file.type.startsWith("image/");
let isVideo = file.type.startsWith("video/");
let iconHtml = "";
if (isImage) {
iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
} else if (isVideo) {
iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>`;
} else {
iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`;
}
html += `
<div style="display: flex; align-items: center; gap: 0.8rem; background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: var(--border-width) solid rgba(255, 255, 255, 0.1); padding: 0.5rem 0.5rem 0.5rem 1rem; border-radius: 0.75rem; flex-shrink: 0; max-width: 15.625rem; box-shadow: 0 0.25rem 0.375rem rgba(0,0,0,0.1); transition: transform 0.2s ease;">
<div style="flex-shrink: 0; color: var(--accent-color); display: flex; align-items: center;">${iconHtml}</div>
<div style="font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-color); font-weight: 500; flex-grow: 1;">${escapeHtml(file.name)}</div>
<button onclick="removeDraftAttachment(${index})" style="background: transparent; color: var(--text-color); border: none; width: 1.5rem; height: 1.5rem; flex-shrink: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0.125rem; opacity: 0.6; transition: opacity 0.2s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.6'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" style="width:100%; height:100%;"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>
</button>
</div>`;
});
previewDiv.innerHTML = html;
}
async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) {
if (!currentDmId || !currentDmKey) return;
let input = document.getElementById("chat-input");
let content = input.value.trim();
if (!content) return;
if (!content && currentDraftAttachments.length === 0) return;
let pendingMsgId = "pending_" + Date.now();
let container = document.getElementById("chat-messages");
@ -2597,16 +2879,66 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
let dmKeyBytes = await sha256Bytes(base64ToUint8(currentDmKey));
let encryptedContent = await encryptAesGcmToBase64(content, dmKeyBytes);
let attachmentsPayload = [];
if (currentDraftAttachments.length > 0) {
showAction(processBlah("action.attachment.uploading"), "uploading_atts");
for (let i = 0; i < currentDraftAttachments.length; i++) {
let file = currentDraftAttachments[i];
let arrayBuffer = await file.arrayBuffer();
let encryptedFileBytes = await encryptAesGcmToBytes(new Uint8Array(arrayBuffer), dmKeyBytes);
let nonce = await getNonce(id, passwordHash);
let res;
try {
res = await fetch(`${url}/dm/attachment/staging?id=${id}`, {
method: "POST",
headers: {
"secret": await encryptWithNonce(passwordHash, passwordHash, nonce)
},
body: encryptedFileBytes
});
} catch (e) {
throw new Error("error.network.error");
}
if (res.status === 413) {
throw new Error("error.body.too.large");
}
let text = await res.text();
let decText;
try {
decText = await decryptString(text, passwordHash);
} catch (e) {
throw new Error("error:message.send.failed"); // trigger proper blah notification
}
if (decText && decText.startsWith("success:")) {
attachmentsPayload.push({
id: i,
type: file.type,
name: file.name,
size: file.size,
staging_id: decText.split(":")[1]
});
} else {
throw new Error(decText && decText.startsWith("error:") ? decText : "error:message.send.failed");
}
}
clearAction("uploading_atts");
}
let msgPayload = {
string1: currentDmId,
string2: encryptedContent,
string3: "",
string3: attachmentsPayload.length > 0 ? JSON.stringify(attachmentsPayload) : "",
string4: replyingToMsgId || ""
};
input.value = "";
input.style.height = '2.5rem';
sendbutton.style.height = '2.5rem';
currentDraftAttachments = [];
renderAttachmentsPreview();
window.forceScrollToBottom = true;
replyingToMsgId = null;
@ -2625,12 +2957,20 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
let parts = res.split(":");
if (parts.length > 1 && parts[1] !== "message.sent") {
let newMsgId = parts[1];
let finalAttPayload = "";
if (attachmentsPayload.length > 0) {
finalAttPayload = JSON.stringify(attachmentsPayload.map(a => {
let clone = Object.assign({}, a);
delete clone.staging_id;
return clone;
}));
}
loadedMessages[newMsgId] = {
author: id,
timestamp: Date.now().toString(),
type: "larp.text",
content: encryptedContent,
attachment: "",
attachment: finalAttPayload,
key: "0",
pervious: "",
responded: replyingToMsgId || "",
@ -2640,9 +2980,14 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
}
}
} catch (e) {
clearAction("uploading_atts");
clearAction("msgsending");
console.error(e);
if (e.message && e.message.startsWith("error:")) {
showBlahNotification(e.message);
} else {
showBlahNotification("error:message.send.failed");
}
let pElem = document.getElementById(pendingMsgId);
if (pElem) pElem.remove();
}

View file

@ -141,9 +141,16 @@ var chatScreen = `
<svg xmlns="http://www.w3.org/2000/svg" width="1.2rem" viewBox="0 -960 960 960" fill="currentColor"><path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z"/></svg>
</button>
</div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem;">
<div id="attachments-preview" style="display: none; padding: 0.5rem 1rem; gap: 0.8rem; overflow-x: auto; border-top: var(--border-width) solid var(--light-border-color); background: var(--main-bg-color); align-items: center; flex-shrink: 0;"></div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem; align-items: flex-end; flex-shrink: 0;">
<button id="chat-add-attachment" class="submit-button" onclick="clickAddAttachment(this, event)" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" viewBox="0 0 24 24" fill="none" stroke="var(--main-bg-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<textarea id="chat-input" class="forminput" style="flex-grow: 1; margin: 0; resize: none; min-height: 2.5rem; max-height: 8rem; padding: 0.5rem;" placeholder="{blah(placeholder.message.input)}" onkeydown="handleChatInputKey(event)" oninput="handleChatInputResize(this)"></textarea>
<button id="chat-sendmessage" class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; min-height: 2.5rem; max-height: 8rem;">
<button id="chat-sendmessage" class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; flex-shrink: 0; min-height: 2.5rem; max-height: 8rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" viewBox="0 0 16 16" fill="none">
<path stroke="var(--main-bg-color)" stroke-linecap="round" stroke-linejoin="round" d="M 4.65 8 l -1.875 5.625 l 11.25 -5.625 L 2.775 2.375 l 1.875 5.625 z m 0 0 h 3.75" stroke-width="1.1"></path>
</svg>
@ -231,6 +238,28 @@ var profileContextMenu = `
<span class="blah" style="color: var(--big-red)">title.logout</span>
</button>
`;
var attachmentMenu = `
<button onclick="document.getElementById('attach-take-photo').click(); fixedContextMenu.classList.remove('show');">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 0 24 24" fill="none" stroke="var(--text-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></svg>
<span class="blah">action.attachment.take.photo</span>
</button>
<button onclick="document.getElementById('attach-media').click(); fixedContextMenu.classList.remove('show');">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 0 24 24" fill="none" stroke="var(--text-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
<span class="blah">action.attachment.media</span>
</button>
<button onclick="document.getElementById('attach-file').click(); fixedContextMenu.classList.remove('show');">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 0 24 24" fill="none" stroke="var(--text-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
<span class="blah">action.attachment.file</span>
</button>
<button onclick="console.log('poll'); fixedContextMenu.classList.remove('show');">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 0 24 24" fill="none" stroke="var(--text-color)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
<span class="blah">action.attachment.poll</span>
</button>
<input type="file" id="attach-take-photo" accept="image/*" capture="environment" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-media" accept="image/*,video/*" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
<input type="file" id="attach-file" style="display: none;" onchange="handleAttachmentSelection(event)" multiple>
`;
var addSpaceMenu = `
<button onclick="gotoJoinSpace()">
<svg xmlns="http://www.w3.org/2000/svg" width="1.4rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M440-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h160v80H280q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h320v80H320Zm200 160v-80h160q50 0 85-35t35-85q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H520Z"/></svg>