Open chat on the newest message and keep it pinned

This commit is contained in:
Fisto
2026-09-22 11:37:54 +02:00
parent 7dd45c483a
commit 29df3c6870
3 changed files with 64 additions and 2 deletions
+7
View File
@@ -40,6 +40,13 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [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`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/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) - [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 → 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 STT) and that per-user tracks exist only in the renderer as WebRTC `MediaStreamTrack`s. The
+54 -2
View File
@@ -50,6 +50,8 @@ const state = {
consoleOpen: false, consoleOpen: false,
consoleTab: "events", consoleTab: "events",
mobileTab: localStorage.getItem("veskslop-mobile-tab") ?? "chat", mobileTab: localStorage.getItem("veskslop-mobile-tab") ?? "chat",
renderedChannelId: null,
messagesPinned: true,
voiceReloadAt: 0 voiceReloadAt: 0
}; };
@@ -1099,12 +1101,42 @@ function renderCenterHeader() {
$("#channel-context").textContent = context; $("#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() { function renderMessages() {
const root = $("#message-list"); const root = $("#message-list");
const channelChanged = state.renderedChannelId !== state.channelId;
const pinned = channelChanged || messagesNearBottom();
state.renderedChannelId = state.channelId;
state.messagesPinned = pinned;
root.replaceChildren(); root.replaceChildren();
if (!state.channelId) { if (!state.channelId) {
root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." })); root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." }));
pinMessagesToBottom();
return; return;
} }
@@ -1115,6 +1147,7 @@ function renderMessages() {
text: "No cached messages. The client only holds channels it has loaded." text: "No cached messages. The client only holds channels it has loaded."
}) })
); );
pinMessagesToBottom();
return; return;
} }
@@ -1176,8 +1209,7 @@ function renderMessages() {
root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body)); root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body));
} }
const scroll = $("#message-scroll"); if (pinned) pinMessagesToBottom();
scroll.scrollTop = scroll.scrollHeight;
} }
function ttsToggleButton() { function ttsToggleButton() {
@@ -1529,6 +1561,11 @@ function setMobileTab(tab) {
for (const button of $$(".vk-tab")) button.classList.toggle("active", button.dataset.mobileTab === tab); for (const button of $$(".vk-tab")) button.classList.toggle("active", button.dataset.mobileTab === tab);
if (isMobileLayout()) $(".vk-app").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(); renderRightVoice();
updateMobileBadge(); updateMobileBadge();
} }
@@ -2871,6 +2908,21 @@ function init() {
} }
}); });
$("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true })); $("#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", () => { $("#guilds-show-all").addEventListener("click", () => {
state.visibleGuilds = null; state.visibleGuilds = null;
+3
View File
@@ -797,6 +797,9 @@ button {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 16px 18px 10px; 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 { .vk-message-list {