Goodreads Chatbox Hopefully

Floating instant messaging panel thingy on Goodreads.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Goodreads Chatbox Hopefully
// @namespace    https://rory.goodreads.chat/
// @version      2.5
// @description  Floating instant messaging panel thingy on Goodreads.
// @match        https://www.goodreads.com/*
// @match        https://goodreads.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    const DB_BASE = "https://goodreads-instant-messaging-default-rtdb.firebaseio.com"; // no trailing slash
    const GLOBAL_ROOM_ID = "global";

    function dbUrl(path) {
        return DB_BASE + path + ".json";
    }

    function keyFromName(name) {
        return (name || "User")
            .replace(/[.#$/\[\]\\]/g, "_")
            .trim() || "User";
    }

    function getBaseUsername() {
        let el =
            document.querySelector("a.siteHeader__topLevelLink[href^='/user/show']") ||
            document.querySelector("#userSignIn a[href^='/user/show']") ||
            document.querySelector("a.headerPersonalNav__userName") ||
            document.querySelector("#profileName a");

        if (el && el.textContent.trim()) {
            const name = el.textContent.trim();
            localStorage.setItem("gr_im_username", name);
            return name;
        }

        const stored = localStorage.getItem("gr_im_username");
        if (stored) return stored;

        const anon = "User-" + Math.floor(Math.random() * 9999);
        localStorage.setItem("gr_im_username", anon);
        return anon;
    }

    let USERNAME = getBaseUsername();
    let USER_KEY = keyFromName(USERNAME);

    let BUBBLE_COLOR = localStorage.getItem("gr_im_color") || "#b4ffd9";

    // HEADER DEFAULTS TO BLACK GRADIENT
    let THEME_START = localStorage.getItem("gr_im_theme_start") || "#000000";
    let THEME_END   = localStorage.getItem("gr_im_theme_end")   || "#000000";

    let STATUS_MESSAGE = localStorage.getItem("gr_im_status") || "";

    let SOUND_ENABLED = (localStorage.getItem("gr_im_sound") || "1") === "1";

    let chatOpen = false;
    let currentRoomId = GLOBAL_ROOM_ID;
    let currentRoomLabel = "Global Chat";

    let messagesContainer = null;
    let inputEl = null;
    let typingBar = null;
    let onlineBar = null;
    let panel = null;

    let emojiPanelVisible = false;
    let emojiPanel = null;

    let statusLabel = null;

    let lastMessageTimestamp = 0;
    let hasInitializedMessages = false;

    let typingDots = 0;

    let audioCtx = null;

    function playPop() {
        if (!SOUND_ENABLED) return;
        try {
            const AudioCtx = window.AudioContext || window.webkitAudioContext;
            if (!AudioCtx) return;

            if (!audioCtx) {
                audioCtx = new AudioCtx();
            }

            const osc = audioCtx.createOscillator();
            const gain = audioCtx.createGain();

            osc.type = "sine";
            osc.frequency.value = 850;

            gain.gain.value = 0.09;
            osc.connect(gain);
            gain.connect(audioCtx.destination);

            const now = audioCtx.currentTime;
            gain.gain.setValueAtTime(0.09, now);
            gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);

            osc.start(now);
            osc.stop(now + 0.2);
        } catch (e) {
            // ignore audio failures
        }
    }

    function injectStyles() {
        if (document.getElementById("gr-im-style")) return;
        const style = document.createElement("style");
        style.id = "gr-im-style";
        style.textContent = `
            @keyframes grImBounceIn {
                0% { transform: translateY(6px); opacity: 0; }
                60% { transform: translateY(-2px); opacity: 1; }
                100% { transform: translateY(0); opacity: 1; }
            }
        `;
        document.head.appendChild(style);
    }

    function isNearBottom() {
        if (!messagesContainer) return true;
        const threshold = 120; // more forgiving
        return messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < threshold;
    }

    function scrollMessagesToBottom(smooth) {
        if (!messagesContainer) return;
        const top = messagesContainer.scrollHeight;
        if (messagesContainer.scrollTo) {
            messagesContainer.scrollTo({ top, behavior: smooth ? "smooth" : "auto" });
        } else {
            messagesContainer.scrollTop = top;
        }
    }

    function createChatUI() {
        if (document.getElementById("gr-im-floating-button")) return;

        injectStyles();

        const btn = document.createElement("div");
        btn.id = "gr-im-floating-button";
        Object.assign(btn.style, {
            position: "fixed",
            bottom: "20px",
            right: "20px",
            width: "46px",
            height: "46px",
            borderRadius: "50%",
            background: "linear-gradient(135deg, #f7b7ff, #b7e3ff)",
            boxShadow: "0 4px 14px rgba(0,0,0,0.4)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            cursor: "pointer",
            fontSize: "22px",
            zIndex: "9999999",
            border: "1px solid rgba(255,255,255,0.75)",
            backdropFilter: "blur(6px)"
        });
        btn.textContent = "💬";

        panel = document.createElement("div");
        panel.id = "gr-im-panel";
        Object.assign(panel.style, {
            position: "fixed",
            bottom: "80px",
            right: "20px",
            width: "320px",
            maxHeight: "450px",
            background: "rgba(12,12,20,0.96)",
            color: "#f5f5f5",
            borderRadius: "16px",
            boxShadow: "0 8px 24px rgba(0,0,0,0.6)",
            border: "1px solid rgba(255,255,255,0.15)",
            display: "none",
            flexDirection: "column",
            overflow: "hidden",
            resize: "none",
            zIndex: "9999999"
        });

        const header = document.createElement("div");
        Object.assign(header.style, {
            padding: "8px 10px",
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            background: `linear-gradient(90deg, ${THEME_START}, ${THEME_END})`,
            borderBottom: "1px solid rgba(255,255,255,0.12)",
            fontSize: "12px",
            textTransform: "uppercase",
            letterSpacing: "0.03em",
            position: "relative",
            zIndex: "5"
        });

        const title = document.createElement("div");
        title.id = "gr-im-room-label";
        title.textContent = currentRoomLabel;

        const headerRight = document.createElement("div");
        Object.assign(headerRight.style, {
            display: "flex",
            alignItems: "center",
            gap: "8px",
            position: "relative",
            zIndex: "10"
        });

        const userBox = document.createElement("div");
        Object.assign(userBox.style, {
            display: "flex",
            flexDirection: "column",
            maxWidth: "140px"
        });

        const userLabel = document.createElement("div");
        userLabel.id = "gr-im-user-label";
        userLabel.textContent = USERNAME;
        Object.assign(userLabel.style, {
            fontSize: "11px",
            opacity: "0.9",
            maxWidth: "140px",
            overflow: "hidden",
            textOverflow: "ellipsis",
            whiteSpace: "nowrap"
        });

        statusLabel = document.createElement("div");
        statusLabel.id = "gr-im-status-label";
        statusLabel.textContent = STATUS_MESSAGE;
        Object.assign(statusLabel.style, {
            fontSize: "10px",
            opacity: "0.75",
            maxWidth: "140px",
            overflow: "hidden",
            textOverflow: "ellipsis",
            whiteSpace: "nowrap"
        });

        userBox.appendChild(userLabel);
        userBox.appendChild(statusLabel);

        const globalBtn = document.createElement("button");
        globalBtn.textContent = "Global";
        Object.assign(globalBtn.style, {
            padding: "2px 6px",
            fontSize: "10px",
            borderRadius: "999px",
            border: "none",
            cursor: "pointer",
            backgroundColor: "rgba(0,0,0,0.2)",
            color: "#fff"
        });
        globalBtn.addEventListener("click", () => {
            switchToRoom(GLOBAL_ROOM_ID, "Global Chat");
        });

        const settingsBtn = document.createElement("button");
        settingsBtn.textContent = "⚙️";
        Object.assign(settingsBtn.style, {
            fontSize: "14px",
            border: "none",
            background: "transparent",
            cursor: "pointer",
            padding: "0 2px"
        });

        headerRight.appendChild(globalBtn);
        headerRight.appendChild(userBox);
        headerRight.appendChild(settingsBtn);
        header.appendChild(title);
        header.appendChild(headerRight);

        const settingsPanel = document.createElement("div");
        settingsPanel.id = "gr-im-settings-panel";
        Object.assign(settingsPanel.style, {
            display: "none",
            padding: "8px 10px",
            borderBottom: "1px solid rgba(255,255,255,0.18)",
            backgroundColor: "rgba(15,15,25,0.98)",
            fontSize: "11px"
        });

        settingsPanel.innerHTML = `
            <div style="display:flex; flex-direction:column; gap:6px;">
                <label style="display:flex; flex-direction:column; gap:2px;">
                    <span style="opacity:0.8;">Username</span>
                    <input id="gr-im-settings-username" type="text"
                        style="padding:4px 6px; border-radius:6px; border:1px solid rgba(255,255,255,0.25);
                               background:#0f0f17; color:#f5f5f5; font-size:11px;"
                        value="${USERNAME}">
                </label>

                <label style="display:flex; flex-direction:column; gap:2px;">
                    <span style="opacity:0.8;">Status message</span>
                    <input id="gr-im-status" type="text"
                        style="padding:4px 6px; border-radius:6px; border:1px solid rgba(255,255,255,0.25);
                               background:#0f0f17; color:#f5f5f5; font-size:11px;"
                        value="${STATUS_MESSAGE}">
                </label>

                <label style="display:flex; flex-direction:column; gap:2px;">
                    <span style="opacity:0.8;">Your message color</span>
                    <input id="gr-im-settings-color" type="color"
                        style="width:60px; height:26px; border-radius:4px; border:none;"
                        value="${BUBBLE_COLOR}">
                </label>

                <div style="display:flex; gap:8px;">
                    <label style="flex:1; display:flex; flex-direction:column; gap:2px;">
                        <span style="opacity:0.8;">Theme start</span>
                        <input id="gr-im-theme-start" type="color"
                            style="width:100%; height:26px; border:none; border-radius:4px;"
                            value="${THEME_START}">
                    </label>

                    <label style="flex:1; display:flex; flex-direction:column; gap:2px;">
                        <span style="opacity:0.8;">Theme end</span>
                        <input id="gr-im-theme-end" type="color"
                            style="width:100%; height:26px; border:none; border-radius:4px;"
                            value="${THEME_END}">
                    </label>
                </div>

                <div style="display:flex; gap:6px; margin-top:4px; flex-wrap:wrap; align-items:center;">
                    <span style="font-size:10px; opacity:0.7;">Presets:</span>
                    <button class="gr-im-theme-preset" data-start="#f7b7ff" data-end="#b7e3ff"
                        style="width:40px; height:18px; border-radius:999px; border:none; cursor:pointer;
                               background:linear-gradient(90deg,#f7b7ff,#b7e3ff);"></button>
                    <button class="gr-im-theme-preset" data-start="#ffcfba" data-end="#ffc1e3"
                        style="width:40px; height:18px; border-radius:999px; border:none; cursor:pointer;
                               background:linear-gradient(90deg,#ffcfba,#ffc1e3);"></button>
                    <button class="gr-im-theme-preset" data-start="#a8e6cf" data-end="#dcedc1"
                        style="width:40px; height:18px; border-radius:999px; border:none; cursor:pointer;
                               background:linear-gradient(90deg,#a8e6cf,#dcedc1);"></button>
                    <button class="gr-im-theme-preset" data-start="#c3cfe2" data-end="#a2afc9"
                        style="width:40px; height:18px; border-radius:999px; border:none; cursor:pointer;
                               background:linear-gradient(90deg,#c3cfe2,#a2afc9);"></button>
                </div>

                <label style="display:flex; align-items:center; gap:6px; margin-top:4px;">
                    <input id="gr-im-sound-enabled" type="checkbox"
                        style="width:14px; height:14px;" ${SOUND_ENABLED ? "checked" : ""}>
                    <span style="opacity:0.85;">Play pop sound on new messages</span>
                </label>

                <button id="gr-im-settings-save"
                    style="align-self:flex-end; margin-top:4px; padding:4px 10px; border-radius:999px;
                           border:none; cursor:pointer; font-size:11px; font-weight:600;
                           background:linear-gradient(135deg,#a0f0c0,#90c8ff); color:#111;">
                    Save
                </button>
            </div>
        `;

        settingsBtn.addEventListener("click", () => {
            settingsPanel.style.display =
                settingsPanel.style.display === "none" ? "block" : "none";
        });

        const saveBtn = settingsPanel.querySelector("#gr-im-settings-save");
        const usernameInput = settingsPanel.querySelector("#gr-im-settings-username");
        const colorInput = settingsPanel.querySelector("#gr-im-settings-color");
        const themeStartInput = settingsPanel.querySelector("#gr-im-theme-start");
        const themeEndInput = settingsPanel.querySelector("#gr-im-theme-end");
        const soundCheckbox = settingsPanel.querySelector("#gr-im-sound-enabled");
        const statusInput = settingsPanel.querySelector("#gr-im-status");

        const presetButtons = settingsPanel.querySelectorAll(".gr-im-theme-preset");
        presetButtons.forEach(btn => {
            btn.addEventListener("click", () => {
                const start = btn.getAttribute("data-start");
                const end = btn.getAttribute("data-end");
                THEME_START = start;
                THEME_END = end;
                themeStartInput.value = start;
                themeEndInput.value = end;
                header.style.background = `linear-gradient(90deg, ${THEME_START}, ${THEME_END})`;
            });
        });

        saveBtn.addEventListener("click", () => {
            const newName = usernameInput.value.trim();
            const newColor = colorInput.value;
            const newStart = themeStartInput.value;
            const newEnd = themeEndInput.value;
            const newStatus = statusInput.value.trim();
            const soundOn = soundCheckbox.checked;

            if (newName) {
                USERNAME = newName;
                USER_KEY = keyFromName(USERNAME);
                localStorage.setItem("gr_im_username", USERNAME);
                userLabel.textContent = USERNAME;
            }

            if (newStatus !== undefined) {
                STATUS_MESSAGE = newStatus;
                localStorage.setItem("gr_im_status", STATUS_MESSAGE);
                statusLabel.textContent = STATUS_MESSAGE;
            }

            if (newColor) {
                BUBBLE_COLOR = newColor;
                localStorage.setItem("gr_im_color", BUBBLE_COLOR);
            }

            if (newStart) {
                THEME_START = newStart;
                localStorage.setItem("gr_im_theme_start", THEME_START);
            }

            if (newEnd) {
                THEME_END = newEnd;
                localStorage.setItem("gr_im_theme_end", THEME_END);
            }

            header.style.background = `linear-gradient(90deg, ${THEME_START}, ${THEME_END})`;

            SOUND_ENABLED = soundOn;
            localStorage.setItem("gr_im_sound", SOUND_ENABLED ? "1" : "0");

            settingsPanel.style.display = "none";
        });

        onlineBar = document.createElement("div");
        Object.assign(onlineBar.style, {
            padding: "4px 8px",
            borderBottom: "1px solid rgba(255,255,255,0.08)",
            fontSize: "11px",
            minHeight: "20px",
            backgroundColor: "rgba(15,15,25,0.95)",
            display: "flex",
            flexWrap: "wrap",
            gap: "4px",
            alignItems: "center"
        });

        messagesContainer = document.createElement("div");
        Object.assign(messagesContainer.style, {
            flex: "1",
            overflowY: "auto",
            padding: "8px 10px",
            fontSize: "13px"
        });

        typingBar = document.createElement("div");
        Object.assign(typingBar.style, {
            padding: "4px 10px",
            fontSize: "11px",
            opacity: "0.75",
            minHeight: "18px"
        });

        const inputWrap = document.createElement("div");
        Object.assign(inputWrap.style, {
            padding: "6px 8px",
            borderTop: "1px solid rgba(255,255,255,0.12)",
            display: "flex",
            gap: "6px",
            alignItems: "center",
            backgroundColor: "rgba(10,10,16,0.98)"
        });

        const emojiBtn = document.createElement("button");
        emojiBtn.textContent = "😊";
        Object.assign(emojiBtn.style, {
            border: "none",
            background: "transparent",
            fontSize: "18px",
            cursor: "pointer"
        });

        emojiPanel = document.createElement("div");
        Object.assign(emojiPanel.style, {
            position: "absolute",
            bottom: "50px",
            right: "10px",
            backgroundColor: "rgba(10,10,15,0.98)",
            borderRadius: "8px",
            border: "1px solid rgba(255,255,255,0.18)",
            padding: "6px 6px",
            display: "none",
            fontSize: "20px",
            zIndex: "999999999999",
            maxWidth: "300px",
            maxHeight: "200px",
            overflowY: "auto",
            flexWrap: "wrap",
            boxShadow: "0 4px 12px rgba(0,0,0,0.7)",
        });

        const emojiList = ["😊","😂","🥺","✨","❤️","🤍","👍","😭","🔥","💫","😎","🙃","🤔","🥹","🌈","⭐","🎧","📚","🩵","💜"];
        emojiList.forEach(e => {
            const span = document.createElement("span");
            span.textContent = e;
            span.style.cursor = "pointer";
            span.style.margin = "2px";
            span.addEventListener("click", () => {
                if (inputEl) {
                    inputEl.value += e;
                    inputEl.focus();
                }
            });
            emojiPanel.appendChild(span);
        });

        inputEl = document.createElement("input");
        Object.assign(inputEl, {
            type: "text",
            placeholder: "Type a message…"
        });
        Object.assign(inputEl.style, {
            flex: "1",
            padding: "8px",
            background: "#1f1f1f",
            borderRadius: "999px",
            border: "1px solid rgba(255,255,255,0.2)",
            color: "#f5f5f5",
            fontSize: "13px"
        });

        const sendBtn = document.createElement("button");
        sendBtn.textContent = "Send";
        Object.assign(sendBtn.style, {
            padding: "6px 12px",
            background: `linear-gradient(135deg, ${THEME_START}, ${THEME_END})`,
            borderRadius: "999px",
            border: "none",
            cursor: "pointer",
            fontSize: "12px",
            fontWeight: "600",
            color: "#111"
        });

        inputWrap.appendChild(emojiBtn);
        inputWrap.appendChild(inputEl);
        inputWrap.appendChild(sendBtn);

        panel.appendChild(header);
        panel.appendChild(settingsPanel);
        panel.appendChild(onlineBar);
        panel.appendChild(messagesContainer);
        panel.appendChild(typingBar);
        panel.appendChild(inputWrap);

        const dragBar = document.createElement("div");
        Object.assign(dragBar.style, {
            position: "absolute",
            top: "0",
            left: "0",
            width: "100%",
            height: "30px",
            cursor: "grab",
            zIndex: "1",
            background: "rgba(255,255,255,0.02)"
        });
        panel.appendChild(dragBar);

        let isDragging = false;
        let dragOffsetX = 0;
        let dragOffsetY = 0;

        dragBar.addEventListener("mousedown", (e) => {
            isDragging = true;
            dragBar.style.cursor = "grabbing";

            const rect = panel.getBoundingClientRect();
            dragOffsetX = e.clientX - rect.left;
            dragOffsetY = e.clientY - rect.top;

            e.preventDefault();
        });

        document.addEventListener("mousemove", (e) => {
            if (!isDragging) return;

            const newX = e.clientX - dragOffsetX;
            const newY = e.clientY - dragOffsetY;

            panel.style.left = newX + "px";
            panel.style.top = newY + "px";

            panel.style.right = "auto";
            panel.style.bottom = "auto";
            panel.style.position = "fixed";
        });

        document.addEventListener("mouseup", () => {
            isDragging = false;
            dragBar.style.cursor = "grab";
        });

        document.body.appendChild(btn);
        document.body.appendChild(panel);
        panel.appendChild(emojiPanel);

        btn.addEventListener("click", () => {
            chatOpen = !chatOpen;
            panel.style.display = chatOpen ? "flex" : "none";
            if (chatOpen) {
                scrollMessagesToBottom(true);
            }
        });

        sendBtn.addEventListener("click", () => {
            sendCurrentMessage();
            scrollMessagesToBottom(true);
        });

        inputEl.addEventListener("keydown", (e) => {
            if (e.key === "Enter") {
                e.preventDefault();
                sendCurrentMessage();
                scrollMessagesToBottom(true);
            }
        });

        emojiBtn.addEventListener("click", () => {
            emojiPanelVisible = !emojiPanelVisible;
            emojiPanel.style.display = emojiPanelVisible ? "flex" : "none";
        });

        let typingTimeout;
        inputEl.addEventListener("input", () => {
            setTyping(true);
            clearTimeout(typingTimeout);
            typingTimeout = setTimeout(() => setTyping(false), 1200);
        });
    }

    function roomIdForDM(a, b) {
        const sA = keyFromName(a);
        const sB = keyFromName(b);
        const pair = [sA, sB].sort();
        return "dm_" + pair.join("__");
    }

    function switchToRoom(roomId, label) {
        currentRoomId = roomId;
        currentRoomLabel = label || (roomId === GLOBAL_ROOM_ID ? "Global Chat" : roomId);
        const labelEl = document.getElementById("gr-im-room-label");
        if (labelEl) labelEl.textContent = currentRoomLabel;

        if (messagesContainer) messagesContainer.innerHTML = "";
        if (typingBar) typingBar.textContent = "";

        fetchMessages();
    }

    async function sendMessageToFirebase(text) {
        const payload = {
            user: USERNAME,
            color: BUBBLE_COLOR,
            text,
            timestamp: Date.now()
        };

        try {
            await fetch(dbUrl(`/rooms/${currentRoomId}/messages`), {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify(payload)
            });
        } catch (e) {
            console.error("Failed to send message:", e);
        }
    }

    function sendCurrentMessage() {
        if (!inputEl) return;
        const text = inputEl.value.trim();
        if (!text) return;
        inputEl.value = "";
        sendMessageToFirebase(text);
    }

    async function fetchMessages() {
        try {
            const res = await fetch(dbUrl(`/rooms/${currentRoomId}/messages`));
            if (!res.ok) return;
            const data = await res.json();
            if (!data) {
                renderMessages([], false, lastMessageTimestamp);
                return;
            }

            const list = Object.entries(data)
                .map(([id, msg]) => ({ id, ...(msg || {}) }))
                .sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));

            const prevLast = lastMessageTimestamp;
            let shouldScroll = false;
            let hadNew = false;

            if (list.length) {
                const newestTs = list[list.length - 1].timestamp || 0;
                if (hasInitializedMessages && newestTs > prevLast) {
                    hadNew = true;
                    shouldScroll = true;
                } else if (!hasInitializedMessages) {
                    shouldScroll = true;
                    hasInitializedMessages = true;
                }
                lastMessageTimestamp = newestTs;
            }

            renderMessages(list, shouldScroll, prevLast, hadNew);
        } catch (e) {
            console.error("Failed to fetch messages:", e);
        }
    }

    async function deleteMessage(id) {
        try {
            await fetch(dbUrl(`/rooms/${currentRoomId}/messages/${id}`), {
                method: "DELETE"
            });
            fetchMessages();
        } catch (e) {
            console.error("Failed to delete message:", e);
        }
    }

    async function editMessage(id, oldText) {
        const newText = prompt("Edit your message:", oldText || "");
        if (newText === null) return;
        const trimmed = newText.trim();
        if (!trimmed) return;

        try {
            await fetch(dbUrl(`/rooms/${currentRoomId}/messages/${id}`), {
                method: "PATCH",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ text: trimmed, edited: true })
            });
            fetchMessages();
        } catch (e) {
            console.error("Failed to edit message:", e);
        }
    }

    async function toggleReaction(messageId, currentReactions, emoji) {
        const reactions = Object.assign({}, currentReactions || {});
        const key = USER_KEY;
        if (reactions[key] === emoji) {
            delete reactions[key];
        } else {
            reactions[key] = emoji;
        }

        try {
            await fetch(dbUrl(`/rooms/${currentRoomId}/messages/${messageId}`), {
                method: "PATCH",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ reactions })
            });
            fetchMessages();
        } catch (e) {
            console.error("Failed to toggle reaction:", e);
        }
    }

    function renderMessages(list, shouldScroll, prevLastTs, hadNew) {
        if (!messagesContainer) return;
        messagesContainer.innerHTML = "";

        const reactionEmojis = ["👍","😂","🥺","❤️","🔥"];

        list.forEach((msg) => {
            const row = document.createElement("div");
            row.style.marginBottom = "6px";
            row.style.padding = "6px 8px";
            row.style.borderRadius = "6px";
            row.style.display = "flex";
            row.style.flexDirection = "column";
            row.style.backgroundColor =
                msg.user === USERNAME
                    ? BUBBLE_COLOR + "22"
                    : "rgba(255,255,255,0.03)";

            const isNew = hadNew && (msg.timestamp || 0) > (prevLastTs || 0);
            if (isNew) {
                row.style.animation = "grImBounceIn 0.18s ease-out";
            }

            const topLine = document.createElement("div");
            topLine.style.display = "flex";
            topLine.style.alignItems = "center";
            topLine.style.justifyContent = "space-between";

            const namePart = document.createElement("div");
            namePart.style.display = "flex";
            namePart.style.alignItems = "center";
            namePart.style.gap = "6px";

            const name = document.createElement("span");
            name.textContent = msg.user || "Unknown";
            name.style.fontWeight = "600";
            name.style.fontSize = "12px";

            const time = document.createElement("span");
            time.style.fontSize = "10px";
            time.style.opacity = "0.6";

            if (msg.timestamp) {
                const d = new Date(msg.timestamp);
                time.textContent = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
                time.title = d.toLocaleString();
            }

            namePart.appendChild(name);
            namePart.appendChild(time);

            const actionPart = document.createElement("div");
            actionPart.style.display = "flex";
            actionPart.style.gap = "4px";
            actionPart.style.fontSize = "11px";

            if (msg.user && msg.user !== USERNAME) {
                const dmBtn = document.createElement("button");
                dmBtn.textContent = "DM";
                Object.assign(dmBtn.style, {
                    border: "none",
                    borderRadius: "999px",
                    padding: "1px 6px",
                    cursor: "pointer",
                    fontSize: "10px",
                    backgroundColor: "rgba(255,255,255,0.08)",
                    color: "#fff"
                });
                dmBtn.addEventListener("click", () => {
                    const roomId = roomIdForDM(USERNAME, msg.user);
                    switchToRoom(roomId, `DM: ${msg.user}`);
                });
                actionPart.appendChild(dmBtn);
            }

            if (msg.user === USERNAME && msg.id) {
                const editBtn = document.createElement("button");
                editBtn.textContent = "✏️";
                Object.assign(editBtn.style, {
                    border: "none",
                    background: "transparent",
                    cursor: "pointer",
                    fontSize: "13px"
                });
                editBtn.addEventListener("click", () => editMessage(msg.id, msg.text || ""));

                const delBtn = document.createElement("button");
                delBtn.textContent = "🗑️";
                Object.assign(delBtn.style, {
                    border: "none",
                    background: "transparent",
                    cursor: "pointer",
                    fontSize: "13px"
                });
                delBtn.addEventListener("click", () => deleteMessage(msg.id));

                actionPart.appendChild(editBtn);
                actionPart.appendChild(delBtn);
            }

            topLine.appendChild(namePart);
            topLine.appendChild(actionPart);

            const textLine = document.createElement("div");
            textLine.style.marginTop = "2px";
            textLine.style.fontSize = "13px";
            textLine.textContent = msg.text || "";

            if (msg.edited) {
                const editedTag = document.createElement("span");
                editedTag.textContent = " (edited)";
                editedTag.style.fontSize = "10px";
                editedTag.style.opacity = "0.6";
                textLine.appendChild(editedTag);
            }

            row.appendChild(topLine);
            row.appendChild(textLine);

            const reactionsWrap = document.createElement("div");
            reactionsWrap.style.display = "flex";
            reactionsWrap.style.alignItems = "center";
            reactionsWrap.style.gap = "6px";
            reactionsWrap.style.marginTop = "3px";

            const reactionsDisplay = document.createElement("div");
            reactionsDisplay.style.display = "flex";
            reactionsDisplay.style.gap = "4px";
            reactionsDisplay.style.fontSize = "11px";

            if (msg.reactions) {
                const counts = {};
                Object.values(msg.reactions).forEach(em => {
                    if (!reactionEmojis.includes(em)) return;
                    counts[em] = (counts[em] || 0) + 1;
                });
                Object.entries(counts).forEach(([em, count]) => {
                    const pill = document.createElement("span");
                    pill.textContent = `${em} ${count}`;
                    Object.assign(pill.style, {
                        padding: "1px 6px",
                        borderRadius: "999px",
                        backgroundColor: "rgba(255,255,255,0.08)",
                        fontSize: "11px"
                    });
                    reactionsDisplay.appendChild(pill);
                });
            }

            const reactionsBar = document.createElement("div");
            reactionsBar.style.display = "none";
            reactionsBar.style.gap = "4px";
            reactionsBar.style.fontSize = "12px";

            reactionEmojis.forEach(em => {
                const b = document.createElement("button");
                b.textContent = em;
                Object.assign(b.style, {
                    border: "none",
                    background: "transparent",
                    cursor: "pointer",
                    padding: "0 2px",
                    fontSize: "13px"
                });
                b.addEventListener("click", (e) => {
                    e.stopPropagation();
                    toggleReaction(msg.id, msg.reactions || {}, em);
                });
                reactionsBar.appendChild(b);
            });

            reactionsWrap.appendChild(reactionsDisplay);
            reactionsWrap.appendChild(reactionsBar);
            row.appendChild(reactionsWrap);

            row.addEventListener("mouseenter", () => {
                reactionsBar.style.display = "flex";
            });
            row.addEventListener("mouseleave", () => {
                reactionsBar.style.display = "none";
            });

            messagesContainer.appendChild(row);
        });

        if (shouldScroll) {
            requestAnimationFrame(() => {
                scrollMessagesToBottom(true);
            });
        }

        if (hadNew) {
            playPop();
        }
    }

    async function setTyping(isTyping) {
        const roomPath = `/typing/${currentRoomId}/${USER_KEY}`;
        try {
            if (isTyping) {
                await fetch(dbUrl(roomPath), {
                    method: "PUT",
                    headers: { "Content-Type": "application/json" },
                    body: JSON.stringify({ displayName: USERNAME, ts: Date.now() })
                });
            } else {
                await fetch(dbUrl(roomPath), { method: "DELETE" });
            }
        } catch (e) {
            console.error("typing error", e);
        }
    }

    async function pollTyping() {
        try {
            const res = await fetch(dbUrl(`/typing/${currentRoomId}`));
            const data = await res.json() || {};

            const names = Object.values(data)
                .map(x => (x && x.displayName) || "")
                .filter(n => n && n !== USERNAME);

            if (!typingBar) return;

            if (names.length === 0) {
                typingBar.textContent = "";
                typingDots = 0;
            } else {
                typingDots = (typingDots + 1) % 3;
                const dots = ".".repeat(typingDots + 1);
                if (names.length === 1) {
                    typingBar.textContent = `${names[0]} is typing${dots}`;
                } else {
                    typingBar.textContent = `${names.length} people are typing${dots}`;
                }
            }
        } catch (e) {
            // ignore
        }
    }

    async function updatePresence() {
        try {
            await fetch(dbUrl(`/presence/${USER_KEY}`), {
                method: "PUT",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ displayName: USERNAME, lastSeen: Date.now() })
            });
        } catch (e) {
            console.error("presence error", e);
        }
    }

    async function pollPresence() {
        try {
            const res = await fetch(dbUrl("/presence"));
            const data = await res.json() || {};
            const now = Date.now();

            const online = Object.values(data)
                .filter(v => v && v.lastSeen && now - v.lastSeen < 60000)
                .map(v => v.displayName)
                .filter(name => !!name);

            renderOnlineList(online);
        } catch (e) {
            // ignore
        }
    }

    function renderOnlineList(onlineNames) {
        if (!onlineBar) return;
        onlineBar.innerHTML = "";

        const meTag = document.createElement("span");
        meTag.textContent = "Online: ";
        meTag.style.opacity = "0.75";
        onlineBar.appendChild(meTag);

        const unique = [...new Set(onlineNames)];
        const others = unique.filter(n => n !== USERNAME);

        if (others.length === 0) {
            const span = document.createElement("span");
            span.textContent = "Just you (for now)";
            span.style.opacity = "0.6";
            onlineBar.appendChild(span);
            return;
        }

        others.forEach(name => {
            const chip = document.createElement("button");
            chip.textContent = name;
            Object.assign(chip.style, {
                border: "none",
                borderRadius: "999px",
                padding: "2px 8px",
                fontSize: "10px",
                cursor: "pointer",
                backgroundColor: "rgba(255,255,255,0.06)",
                color: "#f5f5f5"
            });
            chip.addEventListener("click", () => {
                const roomId = roomIdForDM(USERNAME, name);
                switchToRoom(roomId, `DM: ${name}`);
            });

            onlineBar.appendChild(chip);
        });
    }

    function init() {
        createChatUI();
        fetchMessages();
        updatePresence();

        setInterval(fetchMessages, 2000);
        setInterval(pollTyping, 800);
        setInterval(updatePresence, 15000);
        setInterval(pollPresence, 5000);
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }

})();