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 += `
${showAvatar ? `` : `
`} @@ -2478,7 +2577,8 @@ async function renderMessages(messages, isPrepend = false) { ${timeStr}
` : ""} ${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 = ` -
+ +
+ - `; +var attachmentMenu = ` + + + + + + + +`; + var addSpaceMenu = `
@@ -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 = ` -
+ +
+ - `; +var attachmentMenu = ` + + + + + + + +`; + var addSpaceMenu = `