Redesign message viewer as Telegram-style chat with bubbles, replies, infinite scroll, and orientation
- 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
This commit is contained in:
1 parent
1dfe41950e
commit
c5361f2271
4 files changed
+532
-106
No files matched your search
+230
-55
@@ -1,7 +1,13 @@
|
||||
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) {
|
||||
@@ -13,9 +19,38 @@ async function api(path) {
|
||||
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();
|
||||
const parts = (text || "").split("\n");
|
||||
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));
|
||||
@@ -23,10 +58,21 @@ function textNodeWithBreaks(text) {
|
||||
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;
|
||||
@@ -36,99 +82,228 @@ function renderChannelList() {
|
||||
}
|
||||
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 template = document.getElementById("message-template");
|
||||
if (!append) root.innerHTML = "";
|
||||
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-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.";
|
||||
|
||||
payload.messages.forEach((message) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.messageId = String(message.message_id);
|
||||
node.querySelector(".message-author").textContent = message.sender_name || "Unknown";
|
||||
node.querySelector(".message-date").textContent = message.date || "";
|
||||
if (!append) {
|
||||
root.innerHTML = "";
|
||||
root.appendChild(sentinel);
|
||||
}
|
||||
|
||||
const reply = node.querySelector(".message-reply");
|
||||
if (message.reply_to) {
|
||||
reply.textContent = `Reply to message #${message.reply_to}`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
node.querySelector(".message-text").appendChild(textNodeWithBreaks(message.text || ""));
|
||||
|
||||
const media = node.querySelector(".message-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.views) footerParts.push(`views: ${message.views}`);
|
||||
if (message.forwards) footerParts.push(`forwards: ${message.forwards}`);
|
||||
if (message.reactions) footerParts.push(`reactions: ${message.reactions}`);
|
||||
node.querySelector(".message-footer").textContent = footerParts.join(" | ");
|
||||
|
||||
if (append) {
|
||||
root.prepend(node);
|
||||
} else {
|
||||
root.appendChild(node);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
document.getElementById("load-older-btn").disabled = !payload.messages.length;
|
||||
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}` : "";
|
||||
const payload = await api(`/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`);
|
||||
renderMessages(payload, append);
|
||||
|
||||
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;
|
||||
viewerState.channelId =
|
||||
params.get("channel") ||
|
||||
viewerState.channels[0]?.channel_id ||
|
||||
null;
|
||||
renderChannelList();
|
||||
|
||||
if (viewerState.channelId) {
|
||||
await loadChannel(viewerState.channelId);
|
||||
}
|
||||
|
||||
document.getElementById("load-older-btn").addEventListener("click", async () => {
|
||||
if (!viewerState.channelId || !viewerState.oldestMessageId) return;
|
||||
await loadChannel(viewerState.channelId, true);
|
||||
});
|
||||
setupInfiniteScroll();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
Reference in new issue
Block a user