diff --git a/CURRENT.md b/CURRENT.md index 1e8c463..d8c79a5 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,13 @@ mobile draft at 390px. - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: `previewed: 2`; thumbs served over `/api/media///thumb` (200). + - [x] C78 Chat starts on the newest message. `renderMessages` pins the scroll when the channel + changes or the user is already at the bottom, re-pins on the next frame and after 120/400 ms, + and re-pins on late image loads while pinned. `overflow-anchor: none` on `.vk-message-scroll` + stops Chrome's anchoring from dragging the view off the bottom. A user who scrolled up stays + put across re-renders, the mobile chat tab re-pins when it becomes visible, and opening a guild + channel or DM lands on the newest message. Verified in a browser across all six cases; the UI + smoke test passes. A future jump-to-message link must set `state.messagesPinned = false`. - [x] C77 Split user audio investigation. [docs/voice-audio-split.md](docs/voice-audio-split.md) records that the daemon sees only the mixed client output (`VeskslopCapture.monitor` → VAD → STT) and that per-user tracks exist only in the renderer as WebRTC `MediaStreamTrack`s. The diff --git a/poc/public/app.js b/poc/public/app.js index 96b4e1b..c7259b0 100644 --- a/poc/public/app.js +++ b/poc/public/app.js @@ -50,6 +50,8 @@ const state = { consoleOpen: false, consoleTab: "events", mobileTab: localStorage.getItem("veskslop-mobile-tab") ?? "chat", + renderedChannelId: null, + messagesPinned: true, voiceReloadAt: 0 }; @@ -1099,12 +1101,42 @@ function renderCenterHeader() { $("#channel-context").textContent = context; } +/* Message scroll: a channel opens on the newest message. The view stays pinned + while the user is at the bottom, and never gets dragged by content that loads + late above the viewport. */ +function messagesNearBottom(slack = 80) { + const scroll = $("#message-scroll"); + if (!scroll || !scroll.scrollHeight) return true; + return scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight <= slack; +} + +function pinMessagesToBottom() { + const scroll = $("#message-scroll"); + if (!scroll) return; + + const apply = () => { + scroll.scrollTop = scroll.scrollHeight; + }; + + apply(); + requestAnimationFrame(apply); + // Avatars, attachments, and embeds can change the height after the first paint. + setTimeout(apply, 120); + setTimeout(apply, 400); +} + function renderMessages() { const root = $("#message-list"); + const channelChanged = state.renderedChannelId !== state.channelId; + const pinned = channelChanged || messagesNearBottom(); + + state.renderedChannelId = state.channelId; + state.messagesPinned = pinned; root.replaceChildren(); if (!state.channelId) { root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." })); + pinMessagesToBottom(); return; } @@ -1115,6 +1147,7 @@ function renderMessages() { text: "No cached messages. The client only holds channels it has loaded." }) ); + pinMessagesToBottom(); return; } @@ -1176,8 +1209,7 @@ function renderMessages() { root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body)); } - const scroll = $("#message-scroll"); - scroll.scrollTop = scroll.scrollHeight; + if (pinned) pinMessagesToBottom(); } function ttsToggleButton() { @@ -1529,6 +1561,11 @@ function setMobileTab(tab) { for (const button of $$(".vk-tab")) button.classList.toggle("active", button.dataset.mobileTab === tab); if (isMobileLayout()) $(".vk-app").dataset.mobileTab = tab; + + // The chat panel was hidden during the last render: put the view back on the + // newest message. + if (tab === "chat" && state.messagesPinned) pinMessagesToBottom(); + renderRightVoice(); updateMobileBadge(); } @@ -2871,6 +2908,21 @@ function init() { } }); $("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true })); + $("#message-scroll").addEventListener( + "scroll", + () => { + state.messagesPinned = messagesNearBottom(); + }, + { passive: true } + ); + // Re-pin while the user is at the bottom and late images grow the list. + $("#message-list").addEventListener( + "load", + () => { + if (state.messagesPinned) pinMessagesToBottom(); + }, + true + ); $("#guilds-show-all").addEventListener("click", () => { state.visibleGuilds = null; diff --git a/poc/public/style.css b/poc/public/style.css index cdbbd5a..afbcf10 100644 --- a/poc/public/style.css +++ b/poc/public/style.css @@ -797,6 +797,9 @@ button { min-height: 0; overflow-y: auto; padding: 16px 18px 10px; + /* Late-loading attachments above the viewport must not anchor the scroll + away from the newest message. */ + overflow-anchor: none; } .vk-message-list {