const status = { username: null, lock: true, lastTime: new Date().getTime(), runningID: null, error: 0 }; function main() { addControlBoard(); loadLocalUser(); loadInputGlobal(); autoLoadInfo(); }; function loadInputGlobal() { let count = 0; window.addEventListener("touchstart", event => { count += 1; if (count == 2) { loadInput() }; setTimeout(() => count = 0, 300); }); }; function autoLoadInfo() { status.runningID = setInterval(loadInfo, 1000); }; function addControlBoard() { const addButton = dom("div"); addButton.className = "chat-add-control-board"; addButton.addEventListener("click", loadControlBoard); document.body.appendChild(addButton); }; function loadControlBoard() { const container = dom("div"); container.className = "chat-control-container"; const userContainer = dom("div"); userContainer.className = "chat-control-user-container"; const histContainer = dom("div"); histContainer.className = "chat-control-opt chat-history-opt"; const picUploadButton = makeUploadPicButton(); // user container const userInput = dom("input"); userInput.placeholder = "username"; const userSecret = dom("input"); userSecret.placeholder = "password"; userSecret.type = "password"; const userConfb = dom("div"); userConfb.innerText = "confirm"; userConfb.addEventListener("click", () => { const username = userInput.value; const secret = userSecret.value; loginUser(username, secret); }); userContainer.appendChild(userInput); userContainer.appendChild(userSecret); userContainer.appendChild(userConfb); // hist container const histCounts = [10, 20, 30]; let histCount = 10; const histLabel = dom("div"); histLabel.className = "chat-history-label"; histLabel.innerText = "history"; const histSelector = dom("div"); histSelector.className = "chat-count-selector"; const chips = histCounts.map(count => { const chip = dom("div"); chip.className = "chat-count-chip" + (count === histCount ? " active" : ""); chip.innerText = count; chip.addEventListener("click", () => { histCount = count; chips.forEach(c => c.classList.remove("active")); chip.classList.add("active"); }); histSelector.appendChild(chip); return chip; }); histContainer.appendChild(histLabel); histContainer.appendChild(histSelector); histContainer.addEventListener("click", event => { if (event.target.classList.contains("chat-count-chip")) return; if (status.lock) { return alertBox("please login first"); }; const body = { username: status.username, msgtype: "instant", count: histCount }; clearInterval(status.runningID); loadInfo(body, autoLoadInfo); cancelControlContainer({ target: document.body }); }); if (status.username == null) { container.appendChild(userContainer); }; container.appendChild(histContainer); container.appendChild(picUploadButton); document.body.appendChild(container); setTimeout(() => { window.addEventListener("click", cancelControlContainer); }); }; function cancelControlContainer(event) { const controlContainer = document.querySelector("div.chat-control-container"); if (controlContainer.contains(event.target)) return null; document.body.removeChild(controlContainer); window.removeEventListener("click", cancelControlContainer); }; function makeUploadPicButton() { const button = dom("div"); button.className = "chat-control-opt"; button.innerText = "upload image"; button.addEventListener("click", event => { const input = dom("input"); const attributes = { type: "file", accept: "image/*" }; att(input, attributes); input.addEventListener("change", async event => { const file = input.files[0]; if (!file) return; cancelControlContainer({ target: document.body }); const toast = showToast("上传中..."); try { const key = await uploadToOss(file); toast.remove(); sendMessageWithKey(key); } catch (e) { toast.remove(); alertBox("upload failed: " + e.message); } }); input.click(); }); return button; }; async function getStsToken() { const res = await fetch("/sts-token"); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to get upload token"); return data; }; async function uploadToOss(file) { const sts = await getStsToken(); if (!sts.accessKeyId || !sts.securityToken || !sts.bucket || !sts.region) { throw new Error("Invalid upload token response"); } const client = new OSS({ region: sts.region, accessKeyId: sts.accessKeyId, accessKeySecret: sts.accessKeySecret, stsToken: sts.securityToken, bucket: sts.bucket, }); const ext = file.name.split(".").pop() || "png"; const key = sts.prefix + Date.now() + "." + ext; await client.put(key, file); return key; }; function sendMessageWithKey(key) { if (status.lock) { alertBox("please set username first"); return; } const body = { time: new Date().getTime(), username: status.username, dtype: "img", key: key, msgtype: "instant", }; post("/sendmsg", body, data => {}); }; function loadInput() { const preInput = document.querySelector("div.chat-input-container"); if (preInput) { preInput.style.visibility = "visible"; setTimeout(() => preInput.querySelector("input").focus(), 500); window.addEventListener("touch", stopInput); return null; } const inputInput = dom("input"); const inputContainer = dom("div"); inputContainer.className = "chat-input-container"; inputContainer.appendChild(inputInput); inputInput.addEventListener("keydown", event => { if (event.key.toLowerCase() == "enter") { const msg = event.target.value; if (msg) { sendMessage(msg) }; event.target.value = ""; event.target.blur(); inputContainer.style.visibility = "hidden"; inputContainer.style.top = "0px"; }; }); document.body.appendChild(inputContainer); window.addEventListener("touch", stopInput); setTimeout(() => inputInput.focus(), 500); window.addEventListener("resize", event => { setTimeout(() => { inputContainer.style.top = "350px"; }, 500); }); }; function stopInput(event) { const inputContainer = document.querySelector("div.chat-input-container"); if (inputContainer.contains(event.target)) return null; const input = inputContainer.querySelector("input"); input.blur(); inputContainer.style.visibility = "hidden"; window.removeEventListener("touch", stopInput); }; function sendMessage(msg, dtype) { if (status.lock) { alertBox("please set username first"); }; dtype = dtype || "text"; const body = { time: new Date().getTime(), username: status.username, dtype, msg, msgtype: "instant" }; post("/sendmsg", body, data => {}); }; function loginUser(username, secret) { const body = { username, secret }; post("/login", body, data => { if (data.status == "OK") { status.username = username; status.lock = false; localStorage.setItem("username", username); cancelControlContainer({ target: null }); alertBox("success"); } else { alertBox("invalidate username"); } }); }; function loadLocalUser() { const username = localStorage.getItem("username"); if (username) { status.username = username; status.lock = false; } else { status.username = null; status.lock = true; } }; function loadInfo(body, cb) { if (status.lock) return null; cb = cb || (()=>{}); body = body || { username: status.username, msgtype: "instant", time: [ status.lastTime, new Date().getTime() ] }; post("/getmsg", body, data => { if (data.data.length == 0) return null; const lastTime = data.data.sort((a, b) => b.time - a.time)[0].time; status.lastTime = lastTime; showInfo(data.data); cb(); }); }; function post(url, body, cb) { const header = { "Content-Type": "application/json" }; fetch(url, { headers: header, method: "POST", body: JSON.stringify(body) }) .then(res => res.json()) .then(data => { cb(data); }) .catch(error => { if (error) { status.error += 1; }; if (status.error > 3) { status.error = 0; status.lock = true; status.username = ""; } }); }; function showInfo(data) { // find last info and right now location const textContainer = document.querySelector("div.text"); if (!textContainer) return null; // loop search for the anchor let anchorIndex = 0; for (let i = 0; i < textContainer.childNodes.length; i ++) { anchorIndex = i; const node = textContainer.childNodes[i]; if (node.offsetTop >= window.scrollY) { break; }; }; // seach for previous msg for (let i = 0; i < textContainer.childNodes.length; i ++) { const node = textContainer.childNodes[i]; if (node.classList && node.classList.contains("chat-msg") && (i > anchorIndex)) { anchorIndex = i; }; }; anchorIndex = (anchorIndex == textContainer.childNodes.length - 1) ? anchorIndex: (anchorIndex + 1); const anchorNode = textContainer.childNodes[anchorIndex]; data = data.sort((a, b) => { return a.time - b.time; }); // add data for (let i = 0; i < data.length; i ++) { const infoSpan = makeInfoSpan(data[i]); textContainer.insertBefore(infoSpan, anchorNode); }; }; function makeInfoSpan(data) { let text = ""; const selfCands = [ "李强", "黄明", "赵颖", "王雷" ]; const otherCands = [ "宝说", "小宝说", "大宝说", "亲宝说" ]; const msg = (data.dtype == "text") ? data.msg : "[图片]"; if (data.username == status.username) { const suffix = selfCands[parseInt(Math.random() * selfCands.length)]; text = `"${msg}" ${suffix}`; } else { const prefix = otherCands[parseInt(Math.random() * otherCands.length)]; text = `${prefix} "${msg}"`; }; const span = dom("div"); span.className = "chat-msg"; span.innerText = text; const timeContainer = dom("span"); timeContainer.className = "chat-time-container"; timeContainer.innerText = uniformTime(data.time); span.appendChild(timeContainer); if (data.dtype == "img") { span.classList.add("chat-msg-img"); span.addEventListener("click", async () => { if (span.dataset.loading || span.querySelector("img")) return; span.dataset.loading = "1"; const img = dom("img"); img.className = "chat-img"; img.addEventListener("click", event => { event.stopPropagation(); openImageFullscreen(img.src); }); try { const key = data.key || ("chat-images/" + data.time); const res = await fetch("/signed-url?key=" + encodeURIComponent(key)); const { url } = await res.json(); img.src = url; span.appendChild(img); } catch (e) { alertBox("图片加载失败"); } finally { delete span.dataset.loading; } }); }; return span; }; function openImageFullscreen(src) { const overlay = dom("div"); overlay.className = "img-fullscreen-overlay"; const img = dom("img"); img.src = src; img.className = "img-fullscreen-content"; const closeBtn = dom("div"); closeBtn.className = "img-fullscreen-close"; closeBtn.innerText = "✕"; overlay.appendChild(img); overlay.appendChild(closeBtn); let zoomed = false; function exit() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); document.removeEventListener("keydown", onKeyDown); } // 点击背景 → 退出全屏 overlay.addEventListener("click", exit); // 关闭按钮 closeBtn.addEventListener("click", e => { e.stopPropagation(); exit(); }); // 点击图片 → 切换 fit / 实际尺寸 img.addEventListener("click", e => { e.stopPropagation(); zoomed = !zoomed; if (zoomed) { img.classList.add("img-fullscreen-zoomed"); } else { img.classList.remove("img-fullscreen-zoomed"); } }); // ESC → 退出 function onKeyDown(e) { if (e.key === "Escape") exit(); } document.addEventListener("keydown", onKeyDown); document.body.appendChild(overlay); } function uniformTime(time) { const t = new Date(time); const month = t.getMonth() + 1, day = t.getDate(), hour = t.getHours(), min = t.getMinutes(); return `${month}-${day} ${hour}:${min}`; }; window.addEventListener("load", main);