Better chat UI for gpop.io rooms (scrollback, copy/select, collapsible + unread badge, draggable, resizable, customizable)
// ==UserScript==
// @name BetterChatPop
// @namespace https://gpop.io
// @icon https://www.google.com/s2/favicons?sz=64&domain=gpop.io
// @version 1.2.7
// @description Better chat UI for gpop.io rooms (scrollback, copy/select, collapsible + unread badge, draggable, resizable, customizable)
// @author Purrfect
// @match https://gpop.io
// @match https://gpop.io/room/*
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
"use strict";
// ---------------------------
// Keys / Storage
// ---------------------------
const MOD_FLAG = "__betterchatpop_loaded__";
const STORE_HISTORY = "__betterchatpop_history_v3";
const STORE_SETTINGS = "__betterchatpop_settings_v3";
const STORE_UI = "__betterchatpop_ui_v3"; // x + bottom + w + bodyH + split + collapsed + scrollTop
const MAX_WAIT_MS = 30000;
const TICK_MS = 200;
const MAX_MESSAGES = 1400;
const PERSIST_MESSAGES = 900;
const INPUT_MAXLEN = 300;
// ---------------------------
// Defaults
// ---------------------------
const DEFAULTS = Object.freeze({
keys: {
toggleHide: "z",
openChat: "t",
toggleLock: "", // unbound by default
},
behavior: {
scrollToBottomOnOpenIfUnread: false,
lockUI: false,
showJoinLeave: true,
showServerNotices: true,
blurAfterSend: true,
},
ui: {
fontSize: 12, // px
},
theme: {
bg: "rgba(20, 20, 24, 0.86)",
bg2: "rgba(28, 28, 36, 0.82)",
border: "rgba(255,255,255,0.12)",
border2: "rgba(255,255,255,0.18)",
text: "rgba(248,255,253,0.90)",
dim: "rgba(248,255,253,0.65)",
accent: "rgba(180, 200, 255, 0.80)",
badgeBg: "rgba(255, 70, 90, 0.95)",
badgeText: "rgba(255,255,255,0.95)",
},
colors: {
selfName: null,
selfColor: "#a8c5ff",
nameColors: {},
defaultNameColor: "#a8c5ff",
defaultTextColor: "rgba(248,255,253,0.88)",
},
});
// ---------------------------
// Utilities
// ---------------------------
function extractPlayerId(payload) {
return (
payload?.shortid ??
payload?.player?.shortid ??
payload?.player?.id ??
payload?.player?.pid ??
payload?.id ??
payload?.pid ??
payload?.playerId ??
payload?.userId ??
null
);
}
const now = () => Date.now();
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const micro = typeof queueMicrotask === "function" ? queueMicrotask : (f) => setTimeout(f, 0);
const safeRead = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const safeWrite = (k, v) => { try { localStorage.setItem(k, String(v)); } catch {} };
function deepMerge(base, patch) {
if (!patch || typeof patch !== "object") return base;
const out = Array.isArray(base) ? base.slice() : { ...base };
for (const k of Object.keys(patch)) {
const pv = patch[k];
const bv = out[k];
if (pv && typeof pv === "object" && !Array.isArray(pv) && bv && typeof bv === "object" && !Array.isArray(bv)) {
out[k] = deepMerge(bv, pv);
} else {
out[k] = pv;
}
}
return out;
}
function loadSettings() {
try {
const raw = safeRead(STORE_SETTINGS);
if (!raw) return JSON.parse(JSON.stringify(DEFAULTS));
const parsed = JSON.parse(raw);
return deepMerge(JSON.parse(JSON.stringify(DEFAULTS)), parsed);
} catch {
return JSON.parse(JSON.stringify(DEFAULTS));
}
}
function saveSettings(s) {
try { safeWrite(STORE_SETTINGS, JSON.stringify(s)); } catch {}
}
function loadUI() {
try {
const raw = safeRead(STORE_UI);
if (!raw) {
return { x: 16, bottom: 14, w: 380, bodyH: 290, split: 200, collapsed: false, scrollTop: 0 };
}
const o = JSON.parse(raw);
return {
x: Number.isFinite(+o?.x) ? +o.x : 16,
bottom: Number.isFinite(+o?.bottom) ? +o.bottom : 14,
w: Number.isFinite(+o?.w) ? +o.w : 380,
bodyH: Number.isFinite(+o?.bodyH) ? +o.bodyH : 290,
split: Number.isFinite(+o?.split) ? +o.split : 200,
collapsed: !!o?.collapsed,
scrollTop: Number.isFinite(+o?.scrollTop) ? +o.scrollTop : 0,
};
} catch {
return { x: 16, bottom: 14, w: 380, bodyH: 290, split: 200, collapsed: false, scrollTop: 0 };
}
}
function saveUI(ui) {
try {
safeWrite(STORE_UI, JSON.stringify({
x: ui.x, bottom: ui.bottom, w: ui.w, bodyH: ui.bodyH, split: ui.split,
collapsed: !!ui.collapsed,
scrollTop: Number.isFinite(+ui.scrollTop) ? +ui.scrollTop : 0
}));
} catch {}
}
function loadHistory() {
try {
const raw = safeRead(STORE_HISTORY);
if (!raw) return [];
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr.filter(x => x && typeof x === "object");
} catch {
return [];
}
}
function saveHistory(arr) {
try { safeWrite(STORE_HISTORY, JSON.stringify(arr.slice(-PERSIST_MESSAGES))); } catch {}
}
function waitFor(pred, timeoutMs = MAX_WAIT_MS) {
const start = now();
return new Promise((resolve, reject) => {
(function tick() {
let ok = false;
try { ok = !!pred(); } catch {}
if (ok) return resolve(true);
if (now() - start > timeoutMs) return reject(new Error("timeout"));
setTimeout(tick, TICK_MS);
})();
});
}
function el(tag, props = {}, children = []) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === "style") n.style.cssText = String(v);
else if (k === "class") n.className = String(v);
else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v);
else if (v !== undefined && v !== null) n.setAttribute(k, String(v));
}
for (const c of children) n.appendChild(c);
return n;
}
function parseKeyString(s) {
if (typeof s !== "string") return null;
const t = s.trim();
if (!t) return null;
return t.length === 1 ? t.toLowerCase() : t;
}
function keyLabel(k) {
const s = String(k || "").trim();
return s ? s.toUpperCase() : "—";
}
function isTypingTarget(e) {
const t = e?.target;
if (!t) return false;
const tag = (t.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || t.isContentEditable;
}
function toPlainText(v) {
try {
if (v == null) return "";
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
// DOM element / node
if (typeof Element !== "undefined" && v instanceof Element) {
return String(v.textContent || v.innerText || "").trim();
}
if (typeof Node !== "undefined" && v instanceof Node) {
return String(v.textContent || "").trim();
}
// common shapes
if (typeof v === "object") {
const tc = v.textContent ?? v.innerText ?? v.text ?? v.message ?? v.msg;
if (typeof tc === "string") return tc.trim();
}
return String(v);
} catch {
return "";
}
}
// ---------------------------
// Color helpers (rgba <-> hex)
// ---------------------------
function normalizeHex(hex) {
if (typeof hex !== "string") return null;
let h = hex.trim();
if (!h) return null;
if (h[0] !== "#") h = "#" + h;
if (/^#[0-9a-f]{3}$/i.test(h)) {
const r = h[1], g = h[2], b = h[3];
return ("#" + r + r + g + g + b + b).toLowerCase();
}
if (/^#[0-9a-f]{6}$/i.test(h)) return h.toLowerCase();
return null;
}
function rgbaToParts(rgba) {
if (typeof rgba !== "string") return null;
const s = rgba.trim().toLowerCase();
const m = s.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*(?:,\s*([0-9.]+)\s*)?\)$/);
if (!m) return null;
const r = clamp(Math.round(+m[1]), 0, 255);
const g = clamp(Math.round(+m[2]), 0, 255);
const b = clamp(Math.round(+m[3]), 0, 255);
const a = (typeof m[4] === "undefined") ? 1 : clamp(+m[4], 0, 1);
return { r, g, b, a };
}
function partsToHex({ r, g, b }) {
const to2 = (n) => n.toString(16).padStart(2, "0");
return ("#" + to2(r) + to2(g) + to2(b)).toLowerCase();
}
function hexToParts(hex) {
const h = normalizeHex(hex);
if (!h) return null;
const r = parseInt(h.slice(1, 3), 16);
const g = parseInt(h.slice(3, 5), 16);
const b = parseInt(h.slice(5, 7), 16);
return { r, g, b };
}
function partsToRgba({ r, g, b, a }) {
const aa = Math.round(clamp(a, 0, 1) * 1000) / 1000;
return `rgba(${r}, ${g}, ${b}, ${aa})`;
}
function resolveRgbaToPicker(rgbaString, fallbackRgba) {
const p = rgbaToParts(rgbaString) || rgbaToParts(fallbackRgba) || { r: 255, g: 255, b: 255, a: 1 };
return { hex: partsToHex(p), a: p.a };
}
// ---------------------------
// CSS
// ---------------------------
function ensureCSS() {
if (document.getElementById("__betterchatpop_css")) return;
const css = `
:root{
--bcp-bg: rgba(20, 20, 24, 0.86);
--bcp-bg2: rgba(28, 28, 36, 0.82);
--bcp-border: rgba(255,255,255,0.12);
--bcp-border2: rgba(255,255,255,0.18);
--bcp-text: rgba(248,255,253,0.90);
--bcp-dim: rgba(248,255,253,0.65);
--bcp-accent: rgba(180, 200, 255, 0.80);
--bcp-badge-bg: rgba(255, 70, 90, 0.95);
--bcp-badge-text: rgba(255,255,255,0.95);
--bcp-author-default: #a8c5ff;
--bcp-text-default: rgba(248,255,253,0.88);
--bcp-font: 12px;
}
#__betterchatpop_root{
position: fixed;
left: 16px;
bottom: 14px;
z-index: 2147483646;
width: 380px;
max-width: calc(100vw - 16px);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji", "Segoe UI Emoji";
color: var(--bcp-text);
pointer-events: auto;
user-select: text;
}
#__betterchatpop_panel{
background: var(--bcp-bg);
border: 1px solid var(--bcp-border);
border-bottom: 3px solid var(--bcp-border2);
border-radius: 16px;
box-shadow: 0 14px 40px rgba(0,0,0,0.35);
overflow: hidden;
backdrop-filter: blur(8px);
position: relative;
}
#__betterchatpop_header{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.02));
border-bottom: 1px solid rgba(255,255,255,0.10);
user-select: none;
}
.bcp-draggable #__betterchatpop_header{ cursor: grab; }
.bcp-draggable #__betterchatpop_header:active{ cursor: grabbing; }
.bcp-locked #__betterchatpop_header{ cursor: default; }
#__betterchatpop_title{
display:flex;
align-items:center;
gap:10px;
font-weight: 750;
letter-spacing: 0.2px;
font-size: 13px;
}
#__betterchatpop_badge{
display:none;
min-width: 18px;
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: var(--bcp-badge-bg);
color: var(--bcp-badge-text);
font-weight: 900;
font-size: 12px;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px rgba(0,0,0,0.25);
}
.bcp-btn{
all: unset;
cursor: pointer;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.10);
color: rgba(248,255,253,0.88);
font-size: 12px;
font-weight: 700;
user-select: none;
line-height: 1;
}
.bcp-btn:hover{ background: rgba(255,255,255,0.14); }
.bcp-btn:active{ transform: translateY(1px); }
.bcp-btn-danger{
background: rgba(255, 70, 90, 0.22) !important;
border-color: rgba(255, 70, 90, 0.55) !important;
color: rgba(255, 210, 215, 0.96) !important;
}
#__betterchatpop_body{
display:flex;
flex-direction:column;
gap:8px;
padding: 10px;
}
#__betterchatpop_scroller{
overflow: auto;
padding: 8px 8px;
border-radius: 12px;
background: var(--bcp-bg2);
border: 1px solid rgba(255,255,255,0.10);
}
#__betterchatpop_scroller::-webkit-scrollbar { width: 10px; height: 10px; }
#__betterchatpop_scroller::-webkit-scrollbar-track { background: rgba(0,0,0,0); }
#__betterchatpop_scroller::-webkit-scrollbar-thumb { background: rgba(72, 67, 86, 1); border-radius: 999px }
#__betterchatpop_scroller::-webkit-scrollbar-thumb:hover { background: rgba(72, 67, 86, 0.90); }
.bcp-line{
display:flex;
gap:8px;
padding: 3px 2px;
line-height: 1.25;
font-size: var(--bcp-font);
white-space: pre-wrap;
word-break: break-word;
}
.bcp-author{
color: var(--bcp-author-default);
font-weight: 800;
flex: 0 0 auto;
max-width: 45%;
overflow: hidden;
text-overflow: ellipsis;
}
.bcp-text{
color: var(--bcp-text-default);
flex: 1 1 auto;
}
.bcp-notice{
color: rgba(248,255,253,0.70);
font-weight: 700;
}
.bcp-notice2{
color: rgba(255, 210, 120, 0.90);
font-weight: 900;
}
/* Splitter between scroller and input */
#__betterchatpop_split{
height: 8px;
border-radius: 999px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.10);
cursor: ns-resize;
user-select:none;
}
#__betterchatpop_split:hover{ background: rgba(255,255,255,0.09); }
/* Keep split usable even when locked */
.bcp-locked #__betterchatpop_split{ cursor: ns-resize; opacity: 1; }
#__betterchatpop_inputWrap{
display:flex;
gap:8px;
align-items:flex-end;
min-height: 44px;
}
#__betterchatpop_input{
flex: 1 1 auto;
width: 100%;
resize: none;
min-height: 38px;
max-height: 160px;
padding: 10px 10px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.22);
color: rgba(248,255,253,0.92);
outline: none;
font-size: var(--bcp-font);
line-height: 1.25;
overflow:auto;
/* Hide textarea scrollbars (still scrolls) */
scrollbar-width: none;
-ms-overflow-style: none;
}
#__betterchatpop_input::-webkit-scrollbar{
width: 0 !important;
height: 0 !important;
}
#__betterchatpop_input::placeholder{ color: rgba(248,255,253,0.55); }
/* collapsed mode */
.bcp-collapsed #__betterchatpop_body{ display:none; }
.bcp-collapsed #__betterchatpop_panel{ border-bottom-width: 2px; }
.bcp-collapsed .bcp-hide-when-collapsed{ display:none !important; }
/* settings overlay blocks play */
#__betterchatpop_overlay{
position: fixed;
inset: 0;
z-index: 2147483647;
background: rgba(0,0,0,0.45);
backdrop-filter: blur(4px);
display:none;
}
#__betterchatpop_modal{
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 560px;
max-width: calc(100vw - 28px);
background: var(--bcp-bg);
border: 1px solid var(--bcp-border);
border-bottom: 3px solid var(--bcp-border2);
border-radius: 16px;
box-shadow: 0 20px 70px rgba(0,0,0,0.55);
overflow: hidden;
}
#__betterchatpop_modalHeader{
display:flex;
align-items:center;
justify-content:space-between;
padding: 12px 12px;
border-bottom: 1px solid rgba(255,255,255,0.10);
background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.02));
user-select:none;
}
#__betterchatpop_modalTitle{
display:flex;
align-items:center;
gap:10px;
font-weight: 900;
letter-spacing: 0.3px;
font-size: 13px;
color: rgba(248,255,253,0.92);
}
#__betterchatpop_modalBody{
padding: 12px;
display:flex;
flex-direction:column;
gap: 12px;
max-height: min(72vh, 560px);
overflow:auto;
}
.bcp-card{
background: var(--bcp-bg2);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 14px;
padding: 10px;
}
.bcp-row{
display:flex;
gap:10px;
align-items:center;
justify-content:space-between;
margin: 6px 0;
}
.bcp-label{
font-size: 12px;
font-weight: 800;
color: rgba(248,255,253,0.82);
}
.bcp-sub{
font-size: 11px;
color: rgba(248,255,253,0.62);
margin-top: 2px;
}
.bcp-input{
all: unset;
padding: 7px 10px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(0,0,0,0.22);
color: rgba(248,255,253,0.92);
font-size: 12px;
min-width: 180px;
text-align: center;
}
.bcp-color{
width: 44px;
height: 30px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.25);
border-radius: 10px;
padding: 0;
cursor: pointer;
}
.bcp-divider{
height: 1px;
background: rgba(255,255,255,0.10);
margin: 10px 0;
}
/* Toggle */
.bcp-toggle{
position: relative;
width: 44px;
height: 24px;
border-radius: 999px;
background: rgba(255,255,255,0.10);
border: 1px solid rgba(255,255,255,0.12);
cursor:pointer;
flex: 0 0 auto;
}
.bcp-toggle::after{
content:"";
position:absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 999px;
background: rgba(248,255,253,0.85);
box-shadow: 0 6px 14px rgba(0,0,0,0.25);
transition: transform 140ms ease, background 140ms ease;
}
.bcp-toggle[data-on="1"]{
background: rgba(180,200,255,0.22);
border-color: rgba(180,200,255,0.35);
}
.bcp-toggle[data-on="1"]::after{
transform: translateX(20px);
background: rgba(180,200,255,0.95);
}
/* Color popup */
#__betterchatpop_colorPopup{
position: fixed;
inset: 0;
z-index: 2147483647;
display:none;
background: rgba(0,0,0,0.40);
backdrop-filter: blur(3px);
}
#__betterchatpop_colorPopupBox{
position:absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 360px;
max-width: calc(100vw - 28px);
background: var(--bcp-bg);
border: 1px solid var(--bcp-border);
border-bottom: 3px solid var(--bcp-border2);
border-radius: 16px;
box-shadow: 0 20px 70px rgba(0,0,0,0.55);
overflow:hidden;
}
#__betterchatpop_colorPopupHeader{
display:flex;
align-items:center;
justify-content:space-between;
padding: 12px;
border-bottom: 1px solid rgba(255,255,255,0.10);
background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.02));
user-select:none;
}
#__betterchatpop_colorPopupTitle{
font-weight: 900;
font-size: 13px;
color: rgba(248,255,253,0.92);
}
#__betterchatpop_colorPopupBody{
padding: 12px;
display:flex;
flex-direction:column;
gap: 12px;
}
.bcp-alphaRow{
display:flex;
gap:10px;
align-items:center;
justify-content:space-between;
}
.bcp-range{ width: 220px; }
.bcp-preview{
height: 34px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(0,0,0,0.25);
}
/* resize handles */
.bcp-resize-r, .bcp-resize-b, .bcp-resize-br{
position:absolute;
z-index: 5;
background: transparent;
}
.bcp-resize-r{ top: 0; right: 0; width: 10px; height: 100%; cursor: ew-resize; }
.bcp-resize-b{ left: 0; bottom: 0; width: 100%; height: 10px; cursor: ns-resize; }
.bcp-resize-br{ right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; }
.bcp-locked .bcp-resize-r,
.bcp-locked .bcp-resize-b,
.bcp-locked .bcp-resize-br{ cursor: default; }
/* hide original chat visuals */
.__betterchatpop_hideOldChat .chat{ display:none !important; }
`;
const style = document.createElement("style");
style.id = "__betterchatpop_css";
style.textContent = css;
document.head.appendChild(style);
}
function applyVars(settings) {
const t = settings.theme;
const c = settings.colors;
const root = document.documentElement;
root.style.setProperty("--bcp-bg", String(t.bg));
root.style.setProperty("--bcp-bg2", String(t.bg2));
root.style.setProperty("--bcp-border", String(t.border));
root.style.setProperty("--bcp-border2", String(t.border2));
root.style.setProperty("--bcp-text", String(t.text));
root.style.setProperty("--bcp-dim", String(t.dim));
root.style.setProperty("--bcp-accent", String(t.accent));
root.style.setProperty("--bcp-badge-bg", String(t.badgeBg));
root.style.setProperty("--bcp-badge-text", String(t.badgeText));
root.style.setProperty("--bcp-author-default", String(normalizeHex(c.defaultNameColor) || "#a8c5ff"));
root.style.setProperty("--bcp-text-default", String(c.defaultTextColor || "rgba(248,255,253,0.88)"));
const fs = clamp(+settings.ui.fontSize || 12, 9, 20);
root.style.setProperty("--bcp-font", fs + "px");
}
// ---------------------------
// Main
// ---------------------------
function install() {
if (window[MOD_FLAG]) return;
Object.defineProperty(window, MOD_FLAG, { value: true, enumerable: false });
ensureCSS();
let settings = loadSettings();
let ui = loadUI();
applyVars(settings);
let history = loadHistory().slice(-MAX_MESSAGES);
let unread = 0;
let hadUnreadWhileCollapsed = false;
const playerNameById = new Map(); // id -> name
// UI refs
let root, panel, header, badge, scroller, splitBar, body, inputWrap, input;
let btnHide, btnClear, btnGear;
let overlay, modal;
let colorPopup, colorPopupTitle, colorPicker, alphaRange, alphaLabel, previewBox;
// confirm clear
let clearArmed = false;
let clearDisarmTimer = 0;
// drag / resize / split drag
let dragging = false;
let dragStart = null;
let resizing = false;
let resizeMode = null; // "r" | "b" | "br"
let resizeStart = null;
let splitting = false;
let splitStart = null;
// key capture in settings
let captureTarget = null;
let captureHintEl = null;
// hooks installed flags
let roomInitHooked = false;
function hideOldChat() {
try { document.body.classList.add("__betterchatpop_hideOldChat"); } catch {}
}
function isSettingsOpen() {
return !!overlay && overlay.style.display === "block";
}
function isColorPopupOpen() {
return !!colorPopup && colorPopup.style.display === "block";
}
function disarmClear() {
clearArmed = false;
if (btnClear) {
btnClear.classList.remove("bcp-btn-danger");
btnClear.textContent = "Clear";
}
if (clearDisarmTimer) {
clearTimeout(clearDisarmTimer);
clearDisarmTimer = 0;
}
}
function armClear() {
clearArmed = true;
if (btnClear) {
btnClear.classList.add("bcp-btn-danger");
btnClear.textContent = "Confirm";
}
if (clearDisarmTimer) clearTimeout(clearDisarmTimer);
clearDisarmTimer = setTimeout(() => disarmClear(), 2500);
}
function doClear() {
history = [];
saveHistory(history);
if (scroller) scroller.textContent = "";
unread = 0;
hadUnreadWhileCollapsed = false;
setBadge();
disarmClear();
}
function isAtBottom() {
if (!scroller) return true;
const near = 18;
return scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - near;
}
function scrollToBottom() {
if (!scroller) return;
scroller.scrollTop = scroller.scrollHeight + 999999;
}
function setBadge() {
if (!badge) return;
if (ui.collapsed && unread > 0) {
badge.style.display = "inline-block";
badge.textContent = "!";
} else {
badge.style.display = "none";
}
}
function setCollapsed(v) {
const next = !!v;
if (ui.collapsed === next) return;
if (next && scroller) {
ui.scrollTop = scroller.scrollTop;
saveUI(ui);
}
ui.collapsed = next;
saveUI(ui);
panel?.classList.toggle("bcp-collapsed", ui.collapsed);
if (btnHide) btnHide.textContent = ui.collapsed ? "Open" : "Hide";
if (btnClear) btnClear.style.display = ui.collapsed ? "none" : "inline-block";
if (!ui.collapsed) {
if (hadUnreadWhileCollapsed) {
if (settings.behavior.scrollToBottomOnOpenIfUnread) {
micro(scrollToBottom);
} else {
if (scroller) scroller.scrollTop = ui.scrollTop || 0;
}
}
unread = 0;
hadUnreadWhileCollapsed = false;
setBadge();
} else {
setBadge();
}
}
function setLocked(v) {
const next = !!v;
settings.behavior.lockUI = next;
saveSettings(settings);
if (panel) {
panel.classList.toggle("bcp-locked", next);
panel.classList.toggle("bcp-draggable", !next);
}
if (isSettingsOpen()) rebuildSettingsUI();
}
function setRootPosFromUI() {
if (!root || !body || !scroller) return;
root.style.left = `${clamp(ui.x, 0, Math.max(0, window.innerWidth - 80))}px`;
root.style.bottom = `${clamp(ui.bottom, 0, Math.max(0, window.innerHeight - 80))}px`;
root.style.width = `${clamp(ui.w, 260, Math.max(260, window.innerWidth - 16))}px`;
const maxBodyH = Math.max(160, window.innerHeight - 140);
ui.bodyH = clamp(ui.bodyH, 160, maxBodyH);
body.style.height = `${ui.bodyH}px`;
const minInputArea = 70;
const maxScroller = ui.bodyH - minInputArea - 8;
ui.split = clamp(ui.split, 90, Math.max(90, maxScroller));
scroller.style.height = `${ui.split}px`;
saveUI(ui);
}
function ensureSelfNameBestEffort() {
if (settings.colors.selfName) return;
try {
const s = window?.SERVERDATA?.username;
if (typeof s === "string" && s.trim()) {
settings.colors.selfName = s.trim();
saveSettings(settings);
} else if (typeof localStorage?.username === "string" && localStorage.username.trim()) {
settings.colors.selfName = localStorage.username.trim();
saveSettings(settings);
}
} catch {}
}
function getAuthorColor(name) {
const nm = String(name || "").trim();
if (!nm) return normalizeHex(settings.colors.defaultNameColor) || null;
const self = settings.colors.selfName;
if (self && nm.toLowerCase() === String(self).toLowerCase()) {
return normalizeHex(settings.colors.selfColor) || normalizeHex(settings.colors.defaultNameColor) || null;
}
const map = settings.colors.nameColors || {};
return normalizeHex(map[nm]) || normalizeHex(settings.colors.defaultNameColor) || null;
}
function appendLine(msg) {
if (!scroller) return;
const atBottom = isAtBottom();
const line = document.createElement("div");
line.className = "bcp-line";
if (msg.type === "notice") {
const t = document.createElement("div");
t.className = "bcp-text bcp-notice";
t.textContent = msg.text;
line.appendChild(t);
} else if (msg.type === "notice2") {
const t = document.createElement("div");
t.className = "bcp-text bcp-notice2";
t.textContent = msg.text;
line.appendChild(t);
} else {
const a = document.createElement("div");
a.className = "bcp-author";
a.textContent = msg.author || "Unknown";
const col = getAuthorColor(a.textContent);
if (col) a.style.color = col;
const t = document.createElement("div");
t.className = "bcp-text";
t.textContent = msg.text || "";
line.appendChild(a);
line.appendChild(t);
}
scroller.appendChild(line);
while (scroller.childNodes.length > MAX_MESSAGES) {
scroller.removeChild(scroller.firstChild);
}
if (!ui.collapsed && atBottom) scrollToBottom();
}
function renderAll() {
if (!scroller) return;
scroller.textContent = "";
for (const m of history) appendLine(m);
scrollToBottom();
}
function pushMessage(msg) {
history.push(msg);
if (history.length > MAX_MESSAGES) history = history.slice(-MAX_MESSAGES);
saveHistory(history);
if (panel) {
appendLine(msg);
if (ui.collapsed) {
unread++;
hadUnreadWhileCollapsed = true;
setBadge();
}
}
}
// ---------------------------
// Notice filtering
// ---------------------------
function isServerNoticeText(t) {
const s = String(t || "");
return /connected to server|welcome to gamepop|loading level data|disconnected from room|cannot connect to the server|reconnect/i.test(s);
}
function isJoinedLeftText(t) {
const s = String(t || "");
return /\sjoined\.\s*$|\sleft\.\s*$/i.test(s);
}
function shouldShowNotice(text) {
if (!settings.behavior.showServerNotices) {
if (isServerNoticeText(text)) return false;
if (isJoinedLeftText(text)) return false;
return false;
}
return true;
}
// ---------------------------
// Color popup (color + alpha)
// ---------------------------
let colorCommitFn = null;
function openColorPopup(title, rgbaValue, fallbackRgba, onCommit) {
if (!colorPopup) return;
const pick = resolveRgbaToPicker(rgbaValue, fallbackRgba);
colorPopupTitle.textContent = title;
colorPicker.value = pick.hex;
alphaRange.value = String(Math.round(pick.a * 100));
alphaLabel.textContent = `${Math.round(pick.a * 100)}%`;
const updatePreview = () => {
const p = hexToParts(colorPicker.value) || { r: 255, g: 255, b: 255 };
const a = clamp(+alphaRange.value / 100, 0, 1);
previewBox.style.background = partsToRgba({ ...p, a });
};
alphaRange.oninput = () => {
alphaLabel.textContent = `${clamp(+alphaRange.value, 0, 100)}%`;
updatePreview();
};
colorPicker.oninput = updatePreview;
updatePreview();
colorCommitFn = () => {
const p = hexToParts(colorPicker.value) || { r: 255, g: 255, b: 255 };
const a = clamp(+alphaRange.value / 100, 0, 1);
const rgba = partsToRgba({ ...p, a });
onCommit?.(rgba);
};
colorPopup.style.display = "block";
}
function closeColorPopup(commit = false) {
if (!colorPopup) return;
if (commit && typeof colorCommitFn === "function") colorCommitFn();
colorCommitFn = null;
colorPopup.style.display = "none";
}
// ---------------------------
// Settings Modal
// ---------------------------
function openSettings() {
if (!overlay) return;
overlay.style.display = "block";
rebuildSettingsUI();
}
function closeSettings() {
if (!overlay) return;
overlay.style.display = "none";
captureTarget = null;
if (captureHintEl) captureHintEl.textContent = "";
captureHintEl = null;
refreshPlaceholder();
}
function makeToggle(on, onChange) {
const t = el("div", { class: "bcp-toggle" });
t.dataset.on = on ? "1" : "0";
t.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const next = t.dataset.on !== "1";
t.dataset.on = next ? "1" : "0";
onChange(!!next);
});
return t;
}
function rebuildSettingsUI() {
if (!modal) return;
const mb = modal.querySelector("#__betterchatpop_modalBody");
if (!mb) return;
ensureSelfNameBestEffort();
mb.textContent = "";
// ---- Keys
const keysCard = el("div", { class: "bcp-card" });
keysCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Keys")]));
keysCard.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Click Set and press a key. While settings are open, hotkeys are blocked.")]));
const capHint = el("div", { class: "bcp-sub", style: "color: rgba(255,210,120,0.95); font-weight:900; min-height:16px;" });
captureHintEl = capHint;
function keyRow(label, keyName, allowEmpty = false) {
const row = el("div", { class: "bcp-row" });
const left = el("div", {});
left.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode(label)]));
const right = el("div", { style: "display:flex; gap:8px; align-items:center;" });
const display = el("div", { class: "bcp-input", style: "min-width: 90px;" });
display.textContent = keyLabel(settings.keys[keyName]);
const btnSet = el("button", { class: "bcp-btn", type: "button" });
btnSet.textContent = "Set";
btnSet.addEventListener("click", () => {
captureTarget = keyName;
capHint.textContent = `Press a key for "${label}"...`;
});
right.append(display, btnSet);
if (allowEmpty) {
const btnClearKey = el("button", { class: "bcp-btn bcp-btn-danger", type: "button" });
btnClearKey.textContent = "Unbind";
btnClearKey.addEventListener("click", () => {
settings.keys[keyName] = "";
saveSettings(settings);
capHint.textContent = `"${label}" unbound.`;
rebuildSettingsUI();
refreshPlaceholder();
});
right.append(btnClearKey);
}
row.append(left, right);
return row;
}
keysCard.appendChild(keyRow("Toggle hide/show", "toggleHide"));
keysCard.appendChild(keyRow("Open chat (focus input)", "openChat"));
keysCard.appendChild(keyRow("Toggle lock (optional)", "toggleLock", true));
keysCard.appendChild(capHint);
// ---- Behavior
const behCard = el("div", { class: "bcp-card" });
behCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Behavior")]));
function behaviorRow(label, sub, value, onChange) {
const row = el("div", { class: "bcp-row" });
const left = el("div", {});
left.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode(label)]));
if (sub) left.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode(sub)]));
const tog = makeToggle(!!value, onChange);
row.append(left, tog);
return row;
}
behCard.appendChild(behaviorRow(
"Scroll to bottom on open if unread",
"If messages arrived while hidden, opening chat can jump to the latest line.",
settings.behavior.scrollToBottomOnOpenIfUnread,
(v) => { settings.behavior.scrollToBottomOnOpenIfUnread = v; saveSettings(settings); }
));
behCard.appendChild(behaviorRow(
"Lock chat position/size",
"Prevents dragging and resizing (split still works).",
settings.behavior.lockUI,
(v) => { setLocked(v); }
));
behCard.appendChild(behaviorRow(
"Show join/leave messages",
`Shows "… joined." and "… left." (generated client-side).`,
settings.behavior.showJoinLeave,
(v) => { settings.behavior.showJoinLeave = v; saveSettings(settings); }
));
behCard.appendChild(behaviorRow(
"Show server notices",
"Examples: Connected / Loading / Welcome. Also hides private-server join/leave notices when off.",
settings.behavior.showServerNotices,
(v) => { settings.behavior.showServerNotices = v; saveSettings(settings); }
));
behCard.appendChild(behaviorRow(
"Return to game after sending",
"After pressing Enter, the input will blur (like ESC). Turn off to keep typing.",
settings.behavior.blurAfterSend,
(v) => { settings.behavior.blurAfterSend = v; saveSettings(settings); }
));
// ---- UI
const uiCard = el("div", { class: "bcp-card" });
uiCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("UI")]));
uiCard.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Font size is stored locally.")]));
const fsRow = el("div", { class: "bcp-row" });
const fsLeft = el("div", {});
fsLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Font size (px)")]));
const fsInput = el("input", { class: "bcp-input", type: "text", value: String(clamp(+settings.ui.fontSize || 12, 9, 20)), style: "min-width: 90px;" });
fsInput.addEventListener("input", () => {
const n = clamp(parseInt(fsInput.value, 10) || 12, 9, 20);
settings.ui.fontSize = n;
saveSettings(settings);
applyVars(settings);
});
fsRow.append(fsLeft, fsInput);
uiCard.appendChild(fsRow);
// ---- Theme
const themeCard = el("div", { class: "bcp-card" });
themeCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Theme")]));
themeCard.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Click Edit to adjust (includes alpha).")]));
function themeColorRow(label, key, fallback) {
const row = el("div", { class: "bcp-row" });
const left = el("div", {});
left.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode(label)]));
const btn = el("button", { class: "bcp-btn", type: "button" });
btn.textContent = "Edit";
btn.addEventListener("click", () => {
openColorPopup(label, settings.theme[key], fallback, (rgba) => {
settings.theme[key] = rgba;
saveSettings(settings);
applyVars(settings);
});
});
row.append(left, btn);
return row;
}
themeCard.appendChild(themeColorRow("Panel background", "bg", DEFAULTS.theme.bg));
themeCard.appendChild(themeColorRow("Inner background", "bg2", DEFAULTS.theme.bg2));
themeCard.appendChild(themeColorRow("Panel border", "border", DEFAULTS.theme.border));
themeCard.appendChild(themeColorRow("Panel border (bottom)", "border2", DEFAULTS.theme.border2));
themeCard.appendChild(el("div", { class: "bcp-divider" }));
themeCard.appendChild(themeColorRow("UI text color", "text", DEFAULTS.theme.text));
themeCard.appendChild(themeColorRow("Dim text color", "dim", DEFAULTS.theme.dim));
themeCard.appendChild(themeColorRow("Accent color (fallback name color)", "accent", DEFAULTS.theme.accent));
themeCard.appendChild(el("div", { class: "bcp-divider" }));
themeCard.appendChild(themeColorRow("Unread badge background", "badgeBg", DEFAULTS.theme.badgeBg));
themeCard.appendChild(themeColorRow("Unread badge text", "badgeText", DEFAULTS.theme.badgeText));
// ---- Name & text colors
const colorCard = el("div", { class: "bcp-card" });
colorCard.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Name & text colors")]));
colorCard.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Self + per-user colors are hex. Default message text uses popup (rgba+alpha).")]));
const selfRow = el("div", { class: "bcp-row" });
const selfLeft = el("div", {});
selfLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Your name (for self color)")]));
selfLeft.appendChild(el("div", { class: "bcp-sub" }, [document.createTextNode("Auto-detected, but you can override.")]));
const selfInp = el("input", { class: "bcp-input", type: "text", value: settings.colors.selfName || "" });
selfInp.addEventListener("input", () => {
settings.colors.selfName = selfInp.value.trim() || null;
saveSettings(settings);
});
selfRow.append(selfLeft, selfInp);
colorCard.appendChild(selfRow);
const selfColRow = el("div", { class: "bcp-row" });
const selfColLeft = el("div", {});
selfColLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Your name color")]));
const selfCol = el("input", { class: "bcp-color", type: "color" });
selfCol.value = normalizeHex(settings.colors.selfColor) || "#a8c5ff";
selfCol.addEventListener("input", () => {
settings.colors.selfColor = selfCol.value;
saveSettings(settings);
if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
});
selfColRow.append(selfColLeft, selfCol);
colorCard.appendChild(selfColRow);
const defNameRow = el("div", { class: "bcp-row" });
const defNameLeft = el("div", {});
defNameLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Default name color")]));
const defName = el("input", { class: "bcp-color", type: "color" });
defName.value = normalizeHex(settings.colors.defaultNameColor) || "#a8c5ff";
defName.addEventListener("input", () => {
settings.colors.defaultNameColor = defName.value;
saveSettings(settings);
applyVars(settings);
if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
});
defNameRow.append(defNameLeft, defName);
colorCard.appendChild(defNameRow);
const defTextRow = el("div", { class: "bcp-row" });
const defTextLeft = el("div", {});
defTextLeft.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Default message text color")]));
const defTextBtn = el("button", { class: "bcp-btn", type: "button" });
defTextBtn.textContent = "Edit";
defTextBtn.addEventListener("click", () => {
openColorPopup("Default message text color", settings.colors.defaultTextColor, DEFAULTS.colors.defaultTextColor, (rgba) => {
settings.colors.defaultTextColor = rgba;
saveSettings(settings);
applyVars(settings);
if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
});
});
defTextRow.append(defTextLeft, defTextBtn);
colorCard.appendChild(defTextRow);
colorCard.appendChild(el("div", { class: "bcp-divider" }));
const assignTitle = el("div", { class: "bcp-label" }, [document.createTextNode("Assign user color")]);
const assignSub = el("div", { class: "bcp-sub" }, [document.createTextNode("Type a username exactly as shown in chat, pick a color, then Add/Update.")]);
colorCard.append(assignTitle, assignSub);
const assignRow = el("div", { class: "bcp-row" });
const nameInp = el("input", { class: "bcp-input", type: "text", value: "", placeholder: "Username" });
const colInp = el("input", { class: "bcp-color", type: "color" });
colInp.value = "#a8c5ff";
const btnAdd = el("button", { class: "bcp-btn", type: "button" });
btnAdd.textContent = "Add/Update";
btnAdd.addEventListener("click", () => {
const nm = nameInp.value.trim();
const col = normalizeHex(colInp.value);
if (!nm || !col) return;
if (!settings.colors.nameColors) settings.colors.nameColors = {};
settings.colors.nameColors[nm] = col;
saveSettings(settings);
rebuildSettingsUI();
if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
});
assignRow.append(nameInp, colInp, btnAdd);
colorCard.appendChild(assignRow);
const map = settings.colors.nameColors || {};
const keys = Object.keys(map);
if (keys.length) {
const list = el("div", { class: "bcp-card", style: "margin-top:10px; padding:10px;" });
list.appendChild(el("div", { class: "bcp-label" }, [document.createTextNode("Saved user colors")]));
for (const k of keys.sort((a, b) => a.localeCompare(b))) {
const row = el("div", { class: "bcp-row", style: "margin: 8px 0;" });
const left = el("div", { style: "display:flex; align-items:center; gap:10px;" });
const sw = el("div", { style: `width:18px;height:18px;border-radius:999px;background:${map[k]};border:1px solid rgba(255,255,255,0.18);` });
const nm = el("div", { class: "bcp-label", style: "font-weight:900;" }, [document.createTextNode(k)]);
left.append(sw, nm);
const right = el("div", { style: "display:flex; align-items:center; gap:8px;" });
const btnDel = el("button", { class: "bcp-btn bcp-btn-danger", type: "button" });
btnDel.textContent = "Remove";
btnDel.addEventListener("click", () => {
delete settings.colors.nameColors[k];
saveSettings(settings);
rebuildSettingsUI();
if (scroller) { const old = scroller.scrollTop; renderAll(); scroller.scrollTop = old; }
});
right.append(btnDel);
row.append(left, right);
list.appendChild(row);
}
colorCard.appendChild(list);
}
mb.append(keysCard, behCard, uiCard, themeCard, colorCard);
}
// ---------------------------
// Placeholder helper (dynamic)
// ---------------------------
function refreshPlaceholder() {
if (!input) return;
const k = String(settings.keys.openChat || "t").trim() || "t";
input.placeholder = `Press ${k.toUpperCase()} to chat.`;
}
// ---------------------------
// Build UI
// ---------------------------
function ensureUI() {
if (root && document.body.contains(root)) return;
root = el("div", { id: "__betterchatpop_root" });
panel = el("div", { id: "__betterchatpop_panel" });
header = el("div", { id: "__betterchatpop_header" });
const titleWrap = el("div", { id: "__betterchatpop_title" });
badge = el("div", { id: "__betterchatpop_badge" });
const title = document.createElement("div");
title.textContent = "Chat";
titleWrap.append(title, badge);
const btnWrap = el("div", { style: "display:flex;gap:8px;align-items:center;" });
btnClear = el("button", { class: "bcp-btn bcp-hide-when-collapsed", type: "button" });
btnClear.textContent = "Clear";
btnClear.addEventListener("click", (e) => {
e.stopPropagation();
if (ui.collapsed) return;
if (!clearArmed) { armClear(); return; }
doClear();
});
btnHide = el("button", { class: "bcp-btn", type: "button" });
btnHide.textContent = ui.collapsed ? "Open" : "Hide";
btnHide.addEventListener("click", (e) => {
e.stopPropagation();
disarmClear();
setCollapsed(!ui.collapsed);
});
btnGear = el("button", { class: "bcp-btn", type: "button", title: "Settings" });
btnGear.textContent = "⚙";
btnGear.addEventListener("click", (e) => {
e.stopPropagation();
disarmClear();
openSettings();
});
btnWrap.append(btnClear, btnHide, btnGear);
header.append(titleWrap, btnWrap);
body = el("div", { id: "__betterchatpop_body" });
scroller = el("div", { id: "__betterchatpop_scroller" });
scroller.addEventListener("scroll", () => { if (clearArmed) disarmClear(); });
splitBar = el("div", { id: "__betterchatpop_split" });
inputWrap = el("div", { id: "__betterchatpop_inputWrap" });
input = el("textarea", {
id: "__betterchatpop_input",
rows: "2",
maxlength: String(INPUT_MAXLEN),
});
refreshPlaceholder();
inputWrap.append(input);
body.append(scroller, splitBar, inputWrap);
// resize handles
const hr = el("div", { class: "bcp-resize-r" });
const hb = el("div", { class: "bcp-resize-b" });
const hbr = el("div", { class: "bcp-resize-br" });
panel.append(header, body, hr, hb, hbr);
root.append(panel);
document.body.appendChild(root);
// Settings overlay + modal
overlay = el("div", { id: "__betterchatpop_overlay" });
modal = el("div", { id: "__betterchatpop_modal" });
const mh = el("div", { id: "__betterchatpop_modalHeader" });
const mt = el("div", { id: "__betterchatpop_modalTitle" });
mt.appendChild(document.createTextNode("BetterChatPop Settings"));
const closeBtn = el("button", { class: "bcp-btn", type: "button" });
closeBtn.textContent = "Close";
closeBtn.addEventListener("click", () => closeSettings());
mh.append(mt, closeBtn);
const mb = el("div", { id: "__betterchatpop_modalBody" });
modal.append(mh, mb);
overlay.append(modal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeSettings(); });
document.body.appendChild(overlay);
// Block page hotkeys while Settings overlay is open, but still allow typing inside the modal.
function blockOverlayHotkeys(e) {
// When capturing a new hotkey in settings:
if (captureTarget) {
e.preventDefault();
e.stopPropagation();
const k = parseKeyString(e.key);
if (!k) return;
settings.keys[captureTarget] = k;
saveSettings(settings);
if (captureHintEl) captureHintEl.textContent = `Set to ${keyLabel(k)}.`;
captureTarget = null;
rebuildSettingsUI();
refreshPlaceholder();
return;
}
// Close settings with ESC
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeSettings();
return;
}
// Otherwise: allow the key to be typed, but prevent it from bubbling to page hotkeys
e.stopPropagation();
}
overlay.addEventListener("keydown", blockOverlayHotkeys, false);
overlay.addEventListener("keypress", blockOverlayHotkeys, false);
overlay.addEventListener("keyup", blockOverlayHotkeys, false);
// Color popup
colorPopup = el("div", { id: "__betterchatpop_colorPopup" });
const colorPopupBox = el("div", { id: "__betterchatpop_colorPopupBox" });
const cph = el("div", { id: "__betterchatpop_colorPopupHeader" });
colorPopupTitle = el("div", { id: "__betterchatpop_colorPopupTitle" });
const cpClose = el("button", { class: "bcp-btn", type: "button" });
cpClose.textContent = "Close";
cpClose.addEventListener("click", () => closeColorPopup(false));
cph.append(colorPopupTitle, cpClose);
const cpb = el("div", { id: "__betterchatpop_colorPopupBody" });
colorPicker = el("input", { class: "bcp-color", type: "color", style: "width: 100%; height: 44px; border-radius: 12px;" });
const alphaRow = el("div", { class: "bcp-alphaRow" });
const alphaLeft = el("div", { class: "bcp-label" });
alphaLeft.textContent = "Alpha";
alphaRange = el("input", { class: "bcp-range", type: "range", min: "0", max: "100", value: "100" });
alphaLabel = el("div", { class: "bcp-label", style: "min-width:48px; text-align:right;" });
alphaLabel.textContent = "100%";
alphaRow.append(alphaLeft, alphaRange, alphaLabel);
previewBox = el("div", { class: "bcp-preview" });
const cpActions = el("div", { style: "display:flex; gap:10px; justify-content:flex-end;" });
const cpCancel = el("button", { class: "bcp-btn", type: "button" });
cpCancel.textContent = "Cancel";
cpCancel.addEventListener("click", () => closeColorPopup(false));
const cpSave = el("button", { class: "bcp-btn", type: "button" });
cpSave.textContent = "Save";
cpSave.addEventListener("click", () => closeColorPopup(true));
cpActions.append(cpCancel, cpSave);
cpb.append(colorPicker, alphaRow, previewBox, cpActions);
colorPopupBox.append(cph, cpb);
colorPopup.append(colorPopupBox);
colorPopup.addEventListener("click", (e) => { if (e.target === colorPopup) closeColorPopup(false); });
document.body.appendChild(colorPopup);
// state apply
panel.classList.toggle("bcp-collapsed", ui.collapsed);
if (btnClear) btnClear.style.display = ui.collapsed ? "none" : "inline-block";
setBadge();
// lock UI visual
panel.classList.toggle("bcp-locked", !!settings.behavior.lockUI);
panel.classList.toggle("bcp-draggable", !settings.behavior.lockUI);
// apply position / sizes
setRootPosFromUI();
renderAll();
hideOldChat();
// textarea autosize
function autoResizeTextarea(reset = false) {
if (!input) return;
if (reset) {
input.style.height = "0px";
input.style.height = "38px";
return;
}
input.style.height = "0px";
const h = clamp(input.scrollHeight, 38, 160);
input.style.height = h + "px";
}
input.addEventListener("input", () => autoResizeTextarea(false));
function doSend() {
const text = (input.value || "").trim().slice(0, INPUT_MAXLEN);
if (!text) return false;
try {
if (window.chat && typeof window.chat._$1I === "function") {
window.chat._$1I(text);
}
} catch {}
input.value = "";
autoResizeTextarea(true);
return true;
}
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
try { input.blur(); } catch {}
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
disarmClear();
const sent = doSend();
if (sent && settings.behavior.blurAfterSend) {
try { input.blur(); } catch {}
}
return;
}
e.stopPropagation();
}, true);
input.addEventListener("keypress", (e) => e.stopPropagation(), true);
input.addEventListener("keyup", (e) => e.stopPropagation(), true);
micro(() => autoResizeTextarea(true));
// ---------------------------
// Dragging (bottom anchored)
// ---------------------------
header.addEventListener("mousedown", (e) => {
if (settings.behavior.lockUI) return;
const target = e.target;
if (target && (target.closest?.("button") || target.closest?.("input") || target.closest?.("textarea"))) return;
dragging = true;
disarmClear();
const rect = root.getBoundingClientRect();
dragStart = {
mx: e.clientX,
my: e.clientY,
left: rect.left,
bottom: window.innerHeight - rect.bottom
};
e.preventDefault();
e.stopPropagation();
});
// ---------------------------
// Resizing panel (width + body height)
// ---------------------------
function beginResize(mode, e) {
if (settings.behavior.lockUI) return;
resizing = true;
resizeMode = mode;
disarmClear();
const rect = root.getBoundingClientRect();
resizeStart = { mx: e.clientX, my: e.clientY, w: rect.width, bodyH: ui.bodyH };
e.preventDefault();
e.stopPropagation();
}
hr.addEventListener("mousedown", (e) => beginResize("r", e));
hb.addEventListener("mousedown", (e) => beginResize("b", e));
hbr.addEventListener("mousedown", (e) => beginResize("br", e));
// ---------------------------
// Split drag (ALWAYS allowed, even when locked)
// ---------------------------
splitBar.addEventListener("mousedown", (e) => {
splitting = true;
disarmClear();
splitStart = { my: e.clientY, split: ui.split };
e.preventDefault();
e.stopPropagation();
});
// ---------------------------
// Global mouse move/up
// ---------------------------
window.addEventListener("mousemove", (e) => {
if (dragging && dragStart) {
const dx = e.clientX - dragStart.mx;
const dy = e.clientY - dragStart.my;
ui.x = clamp(dragStart.left + dx, 0, Math.max(0, window.innerWidth - 80));
ui.bottom = clamp(dragStart.bottom - dy, 0, Math.max(0, window.innerHeight - 80));
saveUI(ui);
setRootPosFromUI();
return;
}
if (resizing && resizeStart) {
const dx = e.clientX - resizeStart.mx;
const dy = e.clientY - resizeStart.my;
if (resizeMode === "r" || resizeMode === "br") {
ui.w = clamp(resizeStart.w + dx, 260, Math.max(260, window.innerWidth - 16));
}
if (resizeMode === "b" || resizeMode === "br") {
const maxBodyH = Math.max(160, window.innerHeight - 140);
ui.bodyH = clamp(resizeStart.bodyH + (-dy), 160, maxBodyH);
ui.split = clamp(ui.split, 90, ui.bodyH - 70);
}
saveUI(ui);
setRootPosFromUI();
return;
}
if (splitting && splitStart) {
const dy = e.clientY - splitStart.my;
ui.split = clamp(splitStart.split + dy, 90, ui.bodyH - 70);
saveUI(ui);
setRootPosFromUI();
}
}, { passive: true });
window.addEventListener("mouseup", () => {
dragging = false; dragStart = null;
resizing = false; resizeMode = null; resizeStart = null;
splitting = false; splitStart = null;
});
window.addEventListener("mousedown", (e) => {
if (!clearArmed) return;
if (btnClear && (e.target === btnClear || btnClear.contains(e.target))) return;
disarmClear();
}, true);
window.addEventListener("resize", () => {
ui.x = clamp(ui.x, 0, Math.max(0, window.innerWidth - 80));
ui.bottom = clamp(ui.bottom, 0, Math.max(0, window.innerHeight - 80));
ui.w = clamp(ui.w, 260, Math.max(260, window.innerWidth - 16));
const maxBodyH = Math.max(160, window.innerHeight - 140);
ui.bodyH = clamp(ui.bodyH, 160, maxBodyH);
ui.split = clamp(ui.split, 90, ui.bodyH - 70);
saveUI(ui);
setRootPosFromUI();
});
rebuildSettingsUI();
}
// ---------------------------
// Key handling
// ---------------------------
function handleKeys(e) {
if (isColorPopupOpen()) {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
closeColorPopup(false);
return;
}
e.stopPropagation();
e.stopImmediatePropagation?.();
return;
}
if (isSettingsOpen()) {
if (captureTarget) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
const k = parseKeyString(e.key);
if (!k) return;
settings.keys[captureTarget] = k;
saveSettings(settings);
if (captureHintEl) captureHintEl.textContent = `Set to ${keyLabel(k)}.`;
captureTarget = null;
rebuildSettingsUI();
refreshPlaceholder();
return;
}
e.stopPropagation();
e.stopImmediatePropagation?.();
if (e.key === "Escape") {
e.preventDefault();
closeSettings();
}
return;
}
if (isTypingTarget(e)) return;
const key = parseKeyString(e.key);
if (!key) return;
const toggleKey = parseKeyString(settings.keys.toggleHide);
const openKey = parseKeyString(settings.keys.openChat);
const lockKey = parseKeyString(settings.keys.toggleLock);
if (toggleKey && key === toggleKey) {
ensureUI();
hideOldChat();
e.preventDefault();
e.stopPropagation();
disarmClear();
setCollapsed(!ui.collapsed);
return;
}
if (openKey && key === openKey) {
ensureUI();
hideOldChat();
e.preventDefault();
e.stopPropagation();
disarmClear();
setCollapsed(false);
unread = 0;
hadUnreadWhileCollapsed = false;
setBadge();
micro(() => { try { input?.focus(); } catch {} });
return;
}
if (lockKey && key === lockKey) {
ensureUI();
hideOldChat();
e.preventDefault();
e.stopPropagation();
setLocked(!settings.behavior.lockUI);
return;
}
}
// ---------------------------
// Player name helpers (fix Unknown left for pre-existing players)
// ---------------------------
function getNameFromPayload(payload) {
const n = String(payload?.player?.username ?? payload?.player?.name ?? payload?.username ?? payload?.name ?? payload?.n ?? "").trim();
return n || "Unknown";
}
function getIdFromPayload(payload) {
const id =
payload?.player?.sid ?? payload?.player?.id ?? payload?.sid ?? payload?.id ?? payload?.playerId ?? payload?.player_id;
const s = (id === 0 || id) ? String(id) : "";
return s || "";
}
function rememberPlayer(payload) {
try {
const sid = getIdFromPayload(payload);
const name = getNameFromPayload(payload);
if (sid && name && name !== "Unknown") playerNameById.set(sid, name);
} catch {}
}
function resolveNameForLeave(payload) {
const sid = getIdFromPayload(payload);
const direct = getNameFromPayload(payload);
if (direct && direct !== "Unknown") return direct;
if (sid && playerNameById.has(sid)) return playerNameById.get(sid);
return "Unknown";
}
// Hook room init to fill playerNameById from initial player list
function hookRoomInitForPlayerMap() {
if (roomInitHooked) return true;
try {
if (!window.RoomController || !window.RoomController.prototype) return false;
const proto = window.RoomController.prototype;
const orig = proto._$a2;
if (typeof orig !== "function") return false;
proto._$a2 = function (payload) {
try {
const players = payload?.players;
if (players && typeof players === "object") {
for (const id of Object.keys(players)) {
const p = players[id];
const name = String(p?.username || p?.name || "").trim();
if (id && name) {
playerNameById.set(String(id), name);
}
}
}
} catch {}
return orig.apply(this, arguments);
};
roomInitHooked = true;
return true;
} catch {
return false;
}
}
// ---------------------------
// Hook chat messages from the game
// ---------------------------
function hookChatReceive() {
try {
const chat = window.chat;
if (!chat || chat.__bcpHooked) return false;
// Simple dedupe to avoid double-captures within the same tick
let lastSig = "";
let lastTs = 0;
const shouldAccept = (sig) => {
const t = now();
if (sig && sig === lastSig && (t - lastTs) < 250) return false;
lastSig = sig;
lastTs = t;
return true;
};
// ---- Hook normal chat messages: _$62(author, text)
if (typeof chat._$62 === "function" && !chat._$62.__bcpWrapped) {
const orig62 = chat._$62;
chat._$62 = function (author, text) {
try {
const a = toPlainText(author).trim() || "Unknown";
const m = toPlainText(text).trim();
if (m) {
const sig = `m|${a}|${m}`;
if (shouldAccept(sig)) {
ensureUI();
hideOldChat();
pushMessage({ type: "chat", author: a, text: m, ts: now() });
}
}
} catch {}
return orig62.apply(this, arguments);
};
chat._$62.__bcpWrapped = true;
}
// ---- Hook notices: _$8o(text)
if (typeof chat._$8o === "function" && !chat._$8o.__bcpWrapped) {
const orig8o = chat._$8o;
chat._$8o = function (text) {
try {
const t = toPlainText(text).trim();
if (t) {
const sig = `n|${t}`;
if (shouldAccept(sig)) {
// respect your server notice toggle
if (shouldShowNotice(t)) {
ensureUI();
hideOldChat();
pushMessage({ type: "notice", text: t, ts: now() });
}
}
}
} catch {}
return orig8o.apply(this, arguments);
};
chat._$8o.__bcpWrapped = true;
}
// ---- Optional: keep compatibility with gpop's focus helper (press T)
if (typeof chat._$8b === "function" && !chat._$8b.__bcpWrapped) {
const orig8b = chat._$8b;
chat._$8b = function (evt) {
try {
ensureUI();
hideOldChat();
disarmClear();
setCollapsed(false);
unread = 0;
hadUnreadWhileCollapsed = false;
setBadge();
micro(() => { try { input?.focus(); } catch {} });
try { if (evt?.preventDefault) evt.preventDefault(); } catch {}
} catch {}
try { return orig8b.apply(this, arguments); } catch { return 0; }
};
chat._$8b.__bcpWrapped = true;
}
Object.defineProperty(chat, "__bcpHooked", { value: true, enumerable: false });
return true;
} catch {
return false;
}
}
// ---------------------------
// Hook socket join/leave events (best effort)
// ---------------------------
function hookJoinLeave() {
try {
const s = window.socket || window.SOCKET || window.ioSocket;
if (!s) return false;
if (s.__bcpJL) return true;
if (typeof s.on !== "function") return false;
const getId = (payload) => {
const id =
payload?.shortid ??
payload?.player?.shortid ??
payload?.player?.id ??
payload?.player?.pid ??
payload?.id ??
payload?.pid ??
payload?.playerId ??
payload?.userId ??
null;
return (id === 0 || id) ? String(id) : "";
};
const getName = (payload) => {
const n = String(
payload?.player?.username ??
payload?.player?.name ??
payload?.username ??
payload?.name ??
payload?.player?.n ??
payload?.n ??
""
).trim();
return n;
};
// JOIN
s.on("playerjoin", function (payload) {
try {
const sid = getId(payload);
const name = getName(payload) || "Unknown";
if (sid) playerNameById.set(sid, name);
if (!settings.behavior.showJoinLeave) return;
ensureUI();
hideOldChat();
pushMessage({ type: "notice", text: `${name} joined.`, ts: now() });
} catch {}
});
// LEAVE
s.on("playerleave", function (payload) {
try {
const sid = getId(payload);
// Prefer cache, because payload often misses name on leave
let name = "";
if (sid) name = String(playerNameById.get(sid) || "").trim();
// Fallback to payload name if cache empty
if (!name) name = getName(payload);
if (!name) name = "Unknown";
// remove from cache
if (sid) playerNameById.delete(sid);
if (!settings.behavior.showJoinLeave) return;
ensureUI();
hideOldChat();
pushMessage({ type: "notice", text: `${name} left.`, ts: now() });
} catch {}
});
Object.defineProperty(s, "__bcpJL", { value: true, enumerable: false });
return true;
} catch {
return false;
}
}
// ---------------------------
// Init
// ---------------------------
function boot() {
// key listeners
window.addEventListener("keydown", handleKeys, true);
// install tick hooks
const started = now();
(function tick() {
// stop trying after a while
if (now() - started > MAX_WAIT_MS) return;
try { hookRoomInitForPlayerMap(); } catch {}
try { hookChatReceive(); } catch {}
try { hookJoinLeave(); } catch {}
setTimeout(tick, TICK_MS);
})();
// Ensure UI exists lazily: only when messages arrive or user presses keys.
}
boot();
}
try { install(); } catch {}
})();