From 43a99a8d6d06dcf6423dc0e54a5e4556d6d653cf Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 2 Jun 2026 20:25:06 +0200 Subject: [PATCH] feat(desktop): pure scroll-decision logic for new message list --- apps/desktop/src/lib/scrollController.test.ts | 57 +++++++++++++++++++ apps/desktop/src/lib/scrollController.ts | 43 ++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 apps/desktop/src/lib/scrollController.test.ts create mode 100644 apps/desktop/src/lib/scrollController.ts diff --git a/apps/desktop/src/lib/scrollController.test.ts b/apps/desktop/src/lib/scrollController.test.ts new file mode 100644 index 0000000..8d8e208 --- /dev/null +++ b/apps/desktop/src/lib/scrollController.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController'; + +const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({ + scrollTop, + scrollHeight, + clientHeight, +}); + +describe('isNearBottom', () => { + it('true exactly at the bottom', () => { + expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true); + }); + it('true within threshold', () => { + expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true); + }); + it('false beyond threshold', () => { + expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false); + }); +}); + +describe('isNearTop', () => { + it('true at top', () => { + expect(isNearTop(m(0, 1000, 100), 64)).toBe(true); + }); + it('false past threshold', () => { + expect(isNearTop(m(200, 1000, 100), 64)).toBe(false); + }); +}); + +describe('resolveInitialAnchor', () => { + it('anchors to last row at end by default (no saved position)', () => { + expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' }); + }); + it('anchors to bottom when saved position stuck to bottom', () => { + expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({ + index: 49, + align: 'end', + }); + }); + it('restores the saved row at the top when scrolled up', () => { + expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({ + index: 12, + align: 'start', + }); + }); + it('clamps a stale saved index to the current row count', () => { + expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({ + index: 49, + align: 'start', + }); + }); + it('handles an empty list', () => { + expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' }); + }); +}); diff --git a/apps/desktop/src/lib/scrollController.ts b/apps/desktop/src/lib/scrollController.ts new file mode 100644 index 0000000..95f08d5 --- /dev/null +++ b/apps/desktop/src/lib/scrollController.ts @@ -0,0 +1,43 @@ +// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the +// tricky math is verified without a browser (jsdom has no layout). + +export interface ScrollMetrics { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +} + +/** Distance from the bottom edge is within `threshold` px. */ +export function isNearBottom(m: ScrollMetrics, threshold: number): boolean { + return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold; +} + +/** Scroll offset is within `threshold` px of the top. */ +export function isNearTop(m: ScrollMetrics, threshold: number): boolean { + return m.scrollTop <= threshold; +} + +export interface SavedPosition { + topmostIndex: number; + stickToBottom: boolean; +} + +export interface Anchor { + index: number; + align: 'start' | 'end'; +} + +/** + * Where a freshly-opened chat should start. + * - default / "left at bottom" → last row, aligned to the viewport bottom. + * - "left scrolled up" → the saved top-most row, aligned to the viewport top + * (clamped in case the cached row count shrank). + */ +export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor { + if (rowCount <= 0) return { index: 0, align: 'end' }; + if (saved && !saved.stickToBottom) { + const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1)); + return { index, align: 'start' }; + } + return { index: rowCount - 1, align: 'end' }; +}