make chats allow file uploading
This commit is contained in:
+155
-23
@@ -1,5 +1,7 @@
|
||||
(() => {
|
||||
const PLUGIN_ID = "chat";
|
||||
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const clientIdInput = document.getElementById("client-id");
|
||||
@@ -16,11 +18,14 @@
|
||||
const messagesEl = document.getElementById("messages");
|
||||
const msgInput = document.getElementById("msg-input");
|
||||
const btnSend = document.getElementById("btn-send");
|
||||
const btnAttach = document.getElementById("btn-attach");
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const configToggle = document.getElementById("config-toggle");
|
||||
const configBody = document.getElementById("config-body");
|
||||
|
||||
let chatOpen = false;
|
||||
let sseStream = null;
|
||||
const attachmentUrlCache = new Map();
|
||||
|
||||
clientIdInput.value = params.get("clientId") || "";
|
||||
|
||||
@@ -32,10 +37,11 @@
|
||||
chatOpen = open;
|
||||
statusDot.className = "chat-status-dot" + (open ? " open" : "");
|
||||
statusText.textContent = open ? "Chat is open" : "Chat not opened";
|
||||
btnOpen.disabled = open;
|
||||
btnClose.disabled = !open;
|
||||
msgInput.disabled = !open;
|
||||
btnSend.disabled = !open;
|
||||
btnOpen.disabled = open;
|
||||
btnClose.disabled = !open;
|
||||
msgInput.disabled = !open;
|
||||
btnSend.disabled = !open;
|
||||
btnAttach.disabled = !open;
|
||||
if (open) msgInput.focus();
|
||||
}
|
||||
|
||||
@@ -44,32 +50,107 @@
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
function humanSize(bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / 1024 / 1024).toFixed(2) + " MB";
|
||||
}
|
||||
|
||||
function appendMessage(sender, text, direction, timestamp) {
|
||||
const isOut = direction === "to_target";
|
||||
function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => {
|
||||
const s = r.result;
|
||||
const i = s.indexOf(",");
|
||||
resolve(i < 0 ? s : s.slice(i + 1));
|
||||
};
|
||||
r.onerror = () => reject(r.error || new Error("read failed"));
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAttachmentBlob(id) {
|
||||
if (attachmentUrlCache.has(id)) return attachmentUrlCache.get(id);
|
||||
const { ok, result } = await rpc("get_attachment", { id });
|
||||
if (!ok || !result?.dataB64) return null;
|
||||
const bin = atob(result.dataB64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
const blob = new Blob([bytes], {
|
||||
type: result.mime || "application/octet-stream",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
attachmentUrlCache.set(id, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
function renderAttachment(att, messageId) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "chat-msg-attachment";
|
||||
const isImage = (att.mime || "").startsWith("image/");
|
||||
|
||||
if (isImage) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "chat-msg-image";
|
||||
img.alt = att.name;
|
||||
img.title = `${att.name} (${humanSize(att.size)})`;
|
||||
loadAttachmentBlob(messageId).then((url) => {
|
||||
if (url) img.src = url;
|
||||
});
|
||||
img.addEventListener("click", async () => {
|
||||
const url = await loadAttachmentBlob(messageId);
|
||||
if (url) window.open(url, "_blank");
|
||||
});
|
||||
wrap.appendChild(img);
|
||||
} else {
|
||||
const link = document.createElement("a");
|
||||
link.className = "chat-msg-file";
|
||||
link.href = "#";
|
||||
link.innerHTML = `<i class="fa-solid fa-file"></i> <span class="chat-msg-file-name"></span> <span class="chat-msg-file-size"></span>`;
|
||||
link.querySelector(".chat-msg-file-name").textContent = att.name;
|
||||
link.querySelector(".chat-msg-file-size").textContent = `(${humanSize(att.size)})`;
|
||||
link.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const url = await loadAttachmentBlob(messageId);
|
||||
if (!url) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = att.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
wrap.appendChild(link);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
const isOut = msg.direction === "to_target";
|
||||
const div = document.createElement("div");
|
||||
div.className = "chat-msg " + (isOut ? "outgoing" : "incoming");
|
||||
|
||||
const senderDiv = document.createElement("div");
|
||||
senderDiv.className = "chat-msg-sender";
|
||||
senderDiv.textContent = sender;
|
||||
senderDiv.textContent = msg.sender;
|
||||
div.appendChild(senderDiv);
|
||||
|
||||
const textDiv = document.createElement("div");
|
||||
textDiv.className = "chat-msg-text";
|
||||
textDiv.textContent = text;
|
||||
if (msg.text) {
|
||||
const textDiv = document.createElement("div");
|
||||
textDiv.className = "chat-msg-text";
|
||||
textDiv.textContent = msg.text;
|
||||
div.appendChild(textDiv);
|
||||
}
|
||||
|
||||
if (msg.attachment) {
|
||||
div.appendChild(renderAttachment(msg.attachment, msg.id));
|
||||
}
|
||||
|
||||
const timeDiv = document.createElement("div");
|
||||
timeDiv.className = "chat-msg-time";
|
||||
timeDiv.textContent = formatTime(timestamp || Date.now());
|
||||
|
||||
div.appendChild(senderDiv);
|
||||
div.appendChild(textDiv);
|
||||
timeDiv.textContent = formatTime(msg.timestamp || Date.now());
|
||||
div.appendChild(timeDiv);
|
||||
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
@@ -103,10 +184,10 @@
|
||||
if (!cid) return;
|
||||
const { ok, result } = await rpc("get_history", { clientId: cid });
|
||||
if (!ok || !result) return;
|
||||
for (const url of attachmentUrlCache.values()) URL.revokeObjectURL(url);
|
||||
attachmentUrlCache.clear();
|
||||
messagesEl.innerHTML = "";
|
||||
for (const m of result) {
|
||||
appendMessage(m.sender, m.text, m.direction, m.timestamp);
|
||||
}
|
||||
for (const m of result) appendMessage(m);
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
@@ -116,7 +197,7 @@
|
||||
sseStream.addEventListener("new_message", (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.clientId !== getClientId()) return;
|
||||
appendMessage(data.sender, data.text, data.direction, data.timestamp);
|
||||
appendMessage(data);
|
||||
});
|
||||
|
||||
sseStream.addEventListener("chat_status", (e) => {
|
||||
@@ -128,6 +209,8 @@
|
||||
sseStream.addEventListener("history_cleared", (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.clientId !== getClientId()) return;
|
||||
for (const url of attachmentUrlCache.values()) URL.revokeObjectURL(url);
|
||||
attachmentUrlCache.clear();
|
||||
messagesEl.innerHTML = "";
|
||||
});
|
||||
}
|
||||
@@ -177,7 +260,12 @@
|
||||
await rpc("store_message", { clientId: cid, sender, text });
|
||||
await sendPluginEvent(cid, "chat_message", { from: sender, text });
|
||||
} catch (err) {
|
||||
appendMessage("System", "Failed to send: " + err.message, "from_target", Date.now());
|
||||
appendMessage({
|
||||
sender: "System",
|
||||
text: "Failed to send: " + err.message,
|
||||
direction: "from_target",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -188,6 +276,50 @@
|
||||
}
|
||||
});
|
||||
|
||||
btnAttach.addEventListener("click", () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files?.[0];
|
||||
fileInput.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > MAX_ATTACHMENT_SIZE) {
|
||||
alert(`File is too large (${humanSize(file.size)}). Max is ${humanSize(MAX_ATTACHMENT_SIZE)}.`);
|
||||
return;
|
||||
}
|
||||
const cid = getClientId();
|
||||
if (!cid) { alert("Enter a Client ID first."); return; }
|
||||
|
||||
const sender = operatorNameIn.value.trim() || "Operator";
|
||||
const prevDisabled = btnAttach.disabled;
|
||||
btnAttach.disabled = true;
|
||||
try {
|
||||
const dataB64 = await readFileAsBase64(file);
|
||||
const mime = file.type || "application/octet-stream";
|
||||
await rpc("store_attachment", {
|
||||
clientId: cid,
|
||||
sender,
|
||||
name: file.name,
|
||||
mime,
|
||||
dataB64,
|
||||
});
|
||||
await sendPluginEvent(cid, "chat_attachment", {
|
||||
from: sender,
|
||||
name: file.name,
|
||||
mime,
|
||||
dataB64,
|
||||
});
|
||||
} catch (err) {
|
||||
appendMessage({
|
||||
sender: "System",
|
||||
text: "Failed to send file: " + err.message,
|
||||
direction: "from_target",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} finally {
|
||||
btnAttach.disabled = prevDisabled;
|
||||
}
|
||||
});
|
||||
|
||||
btnClear.addEventListener("click", async () => {
|
||||
const cid = getClientId();
|
||||
if (!cid) return;
|
||||
|
||||
Reference in New Issue
Block a user