c5361f2271
- Chat bubbles with 1px border-radius, angular tail arrows - Own messages right (blue), others left (dark) via sender_id vs user_id - Reply preview with left border accent, clickable scroll to original - Infinite scroll up via IntersectionObserver on scroll-sentinel - Date separators (Today/Yesterday/date) between messages - Full-height messages panel with sidebar (channels) + header - Backend: reply_to_message data in load_messages(), user_id exposed in auth snapshot - Auto-scroll to bottom on channel load (triple scrollTop fallback) - history.scrollRestoration = manual to prevent mid-page jumps
313 lines
9.4 KiB
JavaScript
313 lines
9.4 KiB
JavaScript
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
|
|
|
|
const viewerState = {
|
|
channels: [],
|
|
channelId: null,
|
|
oldestMessageId: null,
|
|
newestMessageId: null,
|
|
userId: null,
|
|
loading: false,
|
|
sentinelObserver: null,
|
|
};
|
|
|
|
async function api(path) {
|
|
const response = await fetch(path);
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(data.error || "Request failed");
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function formatDateHeader(dateStr) {
|
|
if (!dateStr) return "";
|
|
const d = new Date(dateStr.replace(" ", "T"));
|
|
if (isNaN(d.getTime())) return "";
|
|
const now = new Date();
|
|
const diff = now - d;
|
|
const oneDay = 86400000;
|
|
if (diff < oneDay && d.getDate() === now.getDate()) return "Today";
|
|
if (diff < 2 * oneDay && d.getDate() === now.getDate() - 1) return "Yesterday";
|
|
return d.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
|
});
|
|
}
|
|
|
|
function formatTime(dateStr) {
|
|
if (!dateStr) return "";
|
|
const d = new Date(dateStr.replace(" ", "T"));
|
|
if (isNaN(d.getTime())) return "";
|
|
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
function dateKey(dateStr) {
|
|
if (!dateStr) return "";
|
|
return dateStr.slice(0, 10);
|
|
}
|
|
|
|
function textNodeWithBreaks(text) {
|
|
const fragment = document.createDocumentFragment();
|
|
if (!text) return fragment;
|
|
const parts = text.split("\n");
|
|
parts.forEach((part, index) => {
|
|
if (index > 0) fragment.appendChild(document.createElement("br"));
|
|
fragment.appendChild(document.createTextNode(part));
|
|
});
|
|
return fragment;
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
const list = document.getElementById("messages-list");
|
|
if (!list) return;
|
|
const go = () => { list.scrollTop = list.scrollHeight; };
|
|
go();
|
|
requestAnimationFrame(go);
|
|
setTimeout(go, 150);
|
|
}
|
|
|
|
function renderChannelList() {
|
|
const root = document.getElementById("viewer-channel-list");
|
|
if (!root) return;
|
|
root.innerHTML = "";
|
|
const template = document.getElementById("viewer-channel-template");
|
|
if (!template) return;
|
|
viewerState.channels.forEach((channel) => {
|
|
const node = template.content.firstElementChild.cloneNode(true);
|
|
node.querySelector(".viewer-channel-name").textContent = channel.name;
|
|
node.querySelector(".viewer-channel-meta").textContent = `${channel.message_count} messages`;
|
|
if (channel.channel_id === viewerState.channelId) {
|
|
node.classList.add("active");
|
|
}
|
|
node.addEventListener("click", () => {
|
|
viewerState.oldestMessageId = null;
|
|
viewerState.newestMessageId = null;
|
|
loadChannel(channel.channel_id);
|
|
});
|
|
root.appendChild(node);
|
|
});
|
|
}
|
|
|
|
function buildMessageNode(message, root) {
|
|
const template = document.getElementById("message-template");
|
|
const wrap = template.content.firstElementChild.cloneNode(true);
|
|
const bubble = wrap.querySelector(".chat-bubble");
|
|
|
|
bubble.dataset.messageId = String(message.message_id);
|
|
|
|
const isOwn = viewerState.userId && message.sender_id === viewerState.userId;
|
|
if (isOwn) {
|
|
wrap.classList.add("chat-bubble-wrap--own");
|
|
}
|
|
|
|
const sender = bubble.querySelector(".chat-sender");
|
|
sender.textContent = message.sender_name || "";
|
|
|
|
const replyEl = bubble.querySelector(".chat-reply");
|
|
const replyMsg = message.reply_to_message;
|
|
if (message.reply_to) {
|
|
replyEl.classList.remove("hidden");
|
|
bubble.querySelector(".chat-reply-author").textContent =
|
|
replyMsg?.sender_name || `#${message.reply_to}`;
|
|
bubble.querySelector(".chat-reply-text").textContent =
|
|
replyMsg?.text || "(message not available)";
|
|
replyEl.addEventListener("click", () => {
|
|
const target = root.querySelector(`[data-message-id="${message.reply_to}"]`);
|
|
if (target) {
|
|
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
target.classList.add("chat-bubble--highlight");
|
|
setTimeout(() => target.classList.remove("chat-bubble--highlight"), 2000);
|
|
}
|
|
});
|
|
} else {
|
|
replyEl.classList.add("hidden");
|
|
}
|
|
|
|
bubble.querySelector(".chat-text").appendChild(textNodeWithBreaks(message.text || ""));
|
|
|
|
const media = bubble.querySelector(".chat-media");
|
|
if (message.media_url && message.media_kind === "image") {
|
|
const img = document.createElement("img");
|
|
img.src = message.media_url;
|
|
img.loading = "lazy";
|
|
media.appendChild(img);
|
|
} else if (message.media_url && message.media_kind === "video") {
|
|
const video = document.createElement("video");
|
|
video.src = message.media_url;
|
|
video.controls = true;
|
|
video.preload = "metadata";
|
|
media.appendChild(video);
|
|
} else if (message.media_url) {
|
|
const link = document.createElement("a");
|
|
link.href = message.media_url;
|
|
link.target = "_blank";
|
|
link.rel = "noreferrer";
|
|
link.textContent = "Open file";
|
|
media.appendChild(link);
|
|
}
|
|
|
|
const footerParts = [];
|
|
if (message.date) footerParts.push(formatTime(message.date));
|
|
if (message.views) footerParts.push(`views: ${message.views}`);
|
|
if (message.forwards) footerParts.push(`forwards: ${message.forwards}`);
|
|
if (message.reactions) footerParts.push(`reactions: ${message.reactions}`);
|
|
bubble.querySelector(".chat-footer").textContent = footerParts.join(" | ");
|
|
|
|
return { wrap, dateKey: dateKey(message.date) };
|
|
}
|
|
|
|
function makeDateSep(text) {
|
|
const el = document.createElement("div");
|
|
el.className = "chat-date-sep";
|
|
el.textContent = text;
|
|
return el;
|
|
}
|
|
|
|
function renderMessages(payload, append = false) {
|
|
const root = document.getElementById("messages-list");
|
|
const sentinel = document.getElementById("scroll-sentinel");
|
|
if (!root) return;
|
|
|
|
const channel = payload.channel;
|
|
document.getElementById("viewer-title").textContent =
|
|
channel?.name || payload.channel_id;
|
|
document.getElementById("viewer-subtitle").textContent = channel
|
|
? `${channel.message_count} messages, latest: ${channel.last_date || "-"}`
|
|
: "No local database found for this channel yet.";
|
|
|
|
if (!append) {
|
|
root.innerHTML = "";
|
|
root.appendChild(sentinel);
|
|
}
|
|
|
|
let lastKey = null;
|
|
if (append) {
|
|
const firstExisting = root.querySelector("[data-message-id]");
|
|
if (firstExisting) {
|
|
const candidate = firstExisting.closest(".chat-bubble-wrap");
|
|
if (candidate && candidate._dateKey) lastKey = candidate._dateKey;
|
|
}
|
|
}
|
|
|
|
const items = payload.messages.map((m) => {
|
|
const { wrap, dateKey: dk } = buildMessageNode(m, root);
|
|
wrap._dateKey = dk;
|
|
return { wrap, dk };
|
|
});
|
|
|
|
if (append) {
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
const { wrap, dk } = items[i];
|
|
const target = sentinel.nextSibling;
|
|
if (dk && dk !== lastKey) {
|
|
root.insertBefore(makeDateSep(formatDateHeader(payload.messages[i].date)), target);
|
|
lastKey = dk;
|
|
}
|
|
root.insertBefore(wrap, target);
|
|
}
|
|
} else {
|
|
for (let i = 0; i < items.length; i++) {
|
|
const { wrap, dk } = items[i];
|
|
if (dk && dk !== lastKey) {
|
|
root.appendChild(makeDateSep(formatDateHeader(payload.messages[i].date)));
|
|
lastKey = dk;
|
|
}
|
|
root.appendChild(wrap);
|
|
}
|
|
}
|
|
|
|
if (payload.messages.length) {
|
|
viewerState.oldestMessageId = payload.messages[0].message_id;
|
|
viewerState.newestMessageId =
|
|
payload.messages[payload.messages.length - 1].message_id;
|
|
}
|
|
|
|
if (!append) {
|
|
requestAnimationFrame(() => scrollToBottom());
|
|
}
|
|
}
|
|
|
|
async function loadChannel(channelId, append = false) {
|
|
if (viewerState.loading) return;
|
|
viewerState.loading = true;
|
|
viewerState.channelId = channelId;
|
|
renderChannelList();
|
|
|
|
const before =
|
|
append && viewerState.oldestMessageId
|
|
? `&before=${viewerState.oldestMessageId}`
|
|
: "";
|
|
try {
|
|
const payload = await api(
|
|
`/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`
|
|
);
|
|
if (append) {
|
|
const list = document.getElementById("messages-list");
|
|
const prevScrollHeight = list.scrollHeight;
|
|
renderMessages(payload, true);
|
|
requestAnimationFrame(() => {
|
|
list.scrollTop = list.scrollHeight - prevScrollHeight;
|
|
});
|
|
} else {
|
|
renderMessages(payload, false);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load messages:", err);
|
|
} finally {
|
|
viewerState.loading = false;
|
|
}
|
|
}
|
|
|
|
function setupInfiniteScroll() {
|
|
const sentinel = document.getElementById("scroll-sentinel");
|
|
if (!sentinel) return;
|
|
if (viewerState.sentinelObserver) {
|
|
viewerState.sentinelObserver.disconnect();
|
|
}
|
|
viewerState.sentinelObserver = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (
|
|
entry.isIntersecting &&
|
|
viewerState.channelId &&
|
|
viewerState.oldestMessageId &&
|
|
!viewerState.loading
|
|
) {
|
|
loadChannel(viewerState.channelId, true);
|
|
}
|
|
}
|
|
},
|
|
{ root: document.getElementById("messages-list"), threshold: 0.1 }
|
|
);
|
|
viewerState.sentinelObserver.observe(sentinel);
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const authData = await api("/api/auth");
|
|
viewerState.userId = authData.user_id || null;
|
|
} catch (e) {
|
|
console.warn("Could not fetch user ID:", e);
|
|
}
|
|
|
|
viewerState.channels = await api("/api/channels");
|
|
const params = new URLSearchParams(window.location.search);
|
|
viewerState.channelId =
|
|
params.get("channel") ||
|
|
viewerState.channels[0]?.channel_id ||
|
|
null;
|
|
renderChannelList();
|
|
|
|
if (viewerState.channelId) {
|
|
await loadChannel(viewerState.channelId);
|
|
}
|
|
|
|
setupInfiniteScroll();
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
alert(error.message);
|
|
});
|