feat(chat): per-conversation scroll memory + version badge on changelog page
Two small UX polishes:
1. Switching between chats no longer slams you to the bottom. Each
conversation's scroll position (pixel offset + stickToBottom flag)
is remembered in a module-scoped Map for the lifetime of the
renderer process. Discord-style: leave Chat A scrolled up, peek at
another conversation, come back — same spot you were reading.
Chats left at the bottom keep auto-following new messages on return.
Reload resets everything (session-only, no localStorage).
The restore runs once messages.length > 0 to avoid the browser
clamping scrollTop to a near-zero scrollHeight before the message
list has rendered. A small isRestoringRef guard prevents the
programmatic scroll event from immediately overwriting the saved
position with a clamped value.
2. Changelog page now shows a version badge in the header that compares
the installed app version against entries[0].version from the
server-side changelog feed. Three states:
* `vX.Y.Z · aktuell` (emerald) — installed matches latest
* `vX.Y.Z · Update verfügbar` + `neueste: vA.B.C` (amber) — outdated
* `vX.Y.Z` neutral — installed is ahead of the published feed
(dev/test builds)
Semver compare is integer-major.minor.patch with a graceful
garbage-fallback so a malformed version string doesn't false-flag
a current install as outdated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,15 @@ import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
|
||||
const STICK_THRESHOLD = 80;
|
||||
|
||||
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
|
||||
// of ConversationPage when the route param (`id`) changes — switching
|
||||
// chats unmounts/remounts the page in our router setup. Session-only
|
||||
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
||||
// alongside the pixel offset so a chat the user left at the bottom keeps
|
||||
// auto-following new messages when they return; a chat scrolled up
|
||||
// returns to the exact spot the user was reading.
|
||||
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
|
||||
|
||||
export function ConversationPage() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -403,11 +412,41 @@ export function ConversationPage() {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length, stickToBottom]);
|
||||
|
||||
// Restore saved scroll position once the conversation's messages have
|
||||
// actually rendered. The earlier version fired on `[id]` alone and ran
|
||||
// before the message list populated — scrollHeight was still tiny, so
|
||||
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
|
||||
// and the user landed at the top instead of the saved position. By
|
||||
// waiting for `messages.length > 0` we know the rendered scrollHeight
|
||||
// is meaningful. `restoredForRef` ensures the restore runs at most
|
||||
// once per chat switch (subsequent message arrivals don't re-trigger).
|
||||
const restoredForRef = useRef<string | null>(null);
|
||||
const isRestoringRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setStickToBottom(true);
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [id]);
|
||||
if (!el || !id) return;
|
||||
if (restoredForRef.current === id) return;
|
||||
// Wait for the conversation's messages to populate; for a chat that
|
||||
// truly has zero messages the bottom and the top are the same anyway.
|
||||
if (messages.length === 0) return;
|
||||
restoredForRef.current = id;
|
||||
const saved = scrollPositions.get(id);
|
||||
// Suppress handleScroll's persistence during the programmatic scroll
|
||||
// below — otherwise the browser's clamp/normalisation could write a
|
||||
// different scrollTop back into the Map and lose the saved position.
|
||||
isRestoringRef.current = true;
|
||||
if (saved && !saved.stickToBottom) {
|
||||
el.scrollTop = saved.scrollTop;
|
||||
setStickToBottom(false);
|
||||
} else {
|
||||
setStickToBottom(true);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
isRestoringRef.current = false;
|
||||
});
|
||||
}, [id, messages.length]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -416,7 +455,14 @@ export function ConversationPage() {
|
||||
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
||||
setStickToBottom(nextStick);
|
||||
if (nextStick) setNewMessagesWhileAway(0);
|
||||
}, []);
|
||||
// Persist position per chat so re-entering this conversation lands
|
||||
// where the user left off (see scrollPositions module-level Map).
|
||||
// Skipped during the in-flight restore so we don't immediately
|
||||
// overwrite the saved position with a clamped value.
|
||||
if (id && !isRestoringRef.current) {
|
||||
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
|
||||
Reference in New Issue
Block a user