diff --git a/android/app/src/main/assets/public/blah/en-cat.json b/android/app/src/main/assets/public/blah/en-cat.json
index 8051a7fb..515cf00f 100644
--- a/android/app/src/main/assets/public/blah/en-cat.json
+++ b/android/app/src/main/assets/public/blah/en-cat.json
@@ -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"
}
\ No newline at end of file
diff --git a/android/app/src/main/assets/public/blah/en-us.json b/android/app/src/main/assets/public/blah/en-us.json
index 4d5c9155..fdeaf7f6 100644
--- a/android/app/src/main/assets/public/blah/en-us.json
+++ b/android/app/src/main/assets/public/blah/en-us.json
@@ -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"
}
\ No newline at end of file
diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js
index da56db1a..0e5ce6c2 100644
--- a/android/app/src/main/assets/public/main.js
+++ b/android/app/src/main/assets/public/main.js
@@ -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 ? `` : ``;
+
+ let attachmentsHtml = "";
+ if (msg.attachment && msg.attachment !== "") {
+ if (msg.attachment.startsWith("[")) {
+ try {
+ let atts = JSON.parse(msg.attachment);
+ if (atts.length > 0) {
+ attachmentsHtml += `
`;
+ 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
+ ? `
`
+ : `
`;
+
+ attachmentsHtml += `
+
+ ${iconSvg}
+
${escapeHtml(displayName)}
+ ${sizeStr ? `
${escapeHtml(sizeStr)}
` : ""}
+
+
`;
+ }
+ attachmentsHtml += `
`;
+ }
+ } catch (e) {
+ console.error("Failed to parse attachments", e);
+ }
+ } else {
+ attachmentsHtml += `
+
[${processBlah("attachment.legacy")}: ${escapeHtml(msg.attachment)}]
+
`;
+ }
+ }
+
if (decrypted) {
content = escapeHtml(content);
}
@@ -2352,6 +2453,8 @@ async function renderMessages(messages, isPrepend = false) {
let timeStr = date.toLocaleString();
let extraClass = showAvatar ? "with-avatar" : "";
+
+ let unencryptedIcon = (!decrypted && !isRedacted) ? `` : ``;
let repliedHtml = "";
if (msg.responded && 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 ? `` : ``;
-
html += `
` : ""}
${repliedHtml}
- ${redactedIcon}${content}
+ ${unencryptedIcon}${redactedIcon}${content}
+ ${attachmentsHtml}
${reactionsHtml}
@@ -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 = ``;
+
+ 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 = `
`;
+ } else if (type.startsWith("video/")) {
+ container.innerHTML = ``;
+ } 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 = `Error loading
`;
+ 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 = ``;
+ } else if (isVideo) {
+ iconHtml = ``;
+ } else {
+ iconHtml = ``;
+ }
+
+ html += `
+
+
${iconHtml}
+
${escapeHtml(file.name)}
+
+
`;
+ });
+ 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");
@@ -2596,17 +2878,67 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
try {
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);
- showBlahNotification("error:message.send.failed");
+ 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();
}
diff --git a/android/app/src/main/assets/public/screens.js b/android/app/src/main/assets/public/screens.js
index 641fac88..f8dbcd51 100644
--- a/android/app/src/main/assets/public/screens.js
+++ b/android/app/src/main/assets/public/screens.js
@@ -141,9 +141,16 @@ var chatScreen = `
-
+
+
+
-
@@ -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 = ``;
+
+ 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 = `
`;
+ } else if (type.startsWith("video/")) {
+ container.innerHTML = ``;
+ } 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 = `Error loading
`;
+ 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 = ``;
+ } else if (isVideo) {
+ iconHtml = ``;
+ } else {
+ iconHtml = ``;
+ }
+
+ html += `
+
+
${iconHtml}
+
${escapeHtml(file.name)}
+
+
+
+
`;
+ });
+ 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");
@@ -2596,17 +2878,67 @@ async function sendMessage(sendbutton = document.getElementById("chat-sendmessag
try {
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);
- showBlahNotification("error:message.send.failed");
+ 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();
}
diff --git a/webroot/screens.js b/webroot/screens.js
index 641fac88..f8dbcd51 100644
--- a/webroot/screens.js
+++ b/webroot/screens.js
@@ -141,9 +141,16 @@ var chatScreen = `
-