Floating instant messaging panel thingy on Goodreads.
// ==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();
}
})();