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:
@@ -1,15 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
||||
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// Installed app version comes from the preload bridge (process.env.npm_-
|
||||
// package_version at preload build time). Falls back to '0.0.0' outside
|
||||
// Electron so the page still renders in a browser preview.
|
||||
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
|
||||
|
||||
export function ChangelogPage() {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(PAGE_SIZE);
|
||||
|
||||
// Compare the installed version against the top changelog entry. The
|
||||
// server-side changelog is sorted newest-first by the release script, so
|
||||
// entries[0] is always the published latest.
|
||||
const latestVersion = entries?.[0]?.version ?? null;
|
||||
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
|
||||
if (entries === null) return 'loading';
|
||||
if (!latestVersion) return 'current';
|
||||
const cmp = compareSemver(installedVersion, latestVersion);
|
||||
if (cmp === 0) return 'current';
|
||||
if (cmp < 0) return 'outdated';
|
||||
return 'ahead';
|
||||
}, [entries, latestVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -34,7 +52,7 @@ export function ChangelogPage() {
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
||||
<SparklesIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
Was ist neu
|
||||
</h1>
|
||||
@@ -42,6 +60,11 @@ export function ChangelogPage() {
|
||||
Alle Änderungen in dieser App, neueste zuerst.
|
||||
</p>
|
||||
</div>
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
installed={installedVersion}
|
||||
latest={latestVersion}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{entries === null && !error && (
|
||||
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// Compact status chip in the header that tells the user whether their
|
||||
// installed build matches the latest published version. Three visual
|
||||
// tones: emerald (current), amber (outdated → update available), neutral
|
||||
// (loading / unknown). The "ahead" case (dev build > released) shares the
|
||||
// neutral tone since users running it always know what they're doing.
|
||||
function VersionBadge({
|
||||
status,
|
||||
installed,
|
||||
latest,
|
||||
}: {
|
||||
status: 'loading' | 'current' | 'outdated' | 'ahead';
|
||||
installed: string;
|
||||
latest: string | null;
|
||||
}) {
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
|
||||
v{installed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'current') {
|
||||
return (
|
||||
<span
|
||||
title="Du läufst auf der neuesten Version."
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
v{installed} · aktuell
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'outdated' && latest) {
|
||||
return (
|
||||
<span
|
||||
title={`Update verfügbar — neueste Version: v${latest}.`}
|
||||
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
<span>v{installed} · Update verfügbar</span>
|
||||
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
|
||||
>
|
||||
v{installed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Lightweight semver comparator: parses major.minor.patch as ints and
|
||||
// compares numerically. Returns negative if a < b, zero if equal, positive
|
||||
// if a > b. Handles malformed inputs by treating non-numeric segments as
|
||||
// 0 so a typo doesn't flag a perfectly current install as outdated.
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const parse = (s: string): [number, number, number] => {
|
||||
const parts = s.split('.').map((p) => {
|
||||
const n = parseInt(p, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
};
|
||||
const [aMaj, aMin, aPat] = parse(a);
|
||||
const [bMaj, bMin, bPat] = parse(b);
|
||||
if (aMaj !== bMaj) return aMaj - bMaj;
|
||||
if (aMin !== bMin) return aMin - bMin;
|
||||
return aPat - bPat;
|
||||
}
|
||||
|
||||
@@ -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