From 72e131f9c273d15e8d3b5c8a9320ab7fb1d533d4 Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Mon, 16 Feb 2026 16:35:38 -0800 Subject: Fix scroll-to-read functionality across all UIs (V1, V2, V3) --- frontend-vanilla/src/main.test.ts | 37 +++++++++++++++++------------ frontend-vanilla/src/main.ts | 50 ++++++++++++++++++++++++++------------- 2 files changed, 55 insertions(+), 32 deletions(-) (limited to 'frontend-vanilla') diff --git a/frontend-vanilla/src/main.test.ts b/frontend-vanilla/src/main.test.ts index 436db14..8eb537e 100644 --- a/frontend-vanilla/src/main.test.ts +++ b/frontend-vanilla/src/main.test.ts @@ -22,10 +22,9 @@ vi.mock('./api', () => ({ })); // Mock IntersectionObserver as a constructor -let observerCallback: IntersectionObserverCallback; class MockIntersectionObserver { constructor(callback: IntersectionObserverCallback) { - observerCallback = callback; + // unused } observe = vi.fn(); unobserve = vi.fn(); @@ -259,7 +258,8 @@ describe('main application logic', () => { expect(store.sidebarVisible).toBe(!initialVisible); }); - it('should mark item as read when scrolled past', () => { + it('should mark item as read when scrolled past', async () => { + vi.useRealTimers(); const mockItem = { _id: 123, title: 'Scroll Test Item', @@ -277,18 +277,25 @@ describe('main application logic', () => { vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response); - // Simulate item scrolled above viewport (no longer intersecting, bottom above root top) - const entry = { - target: itemEl, - isIntersecting: false, - boundingClientRect: { bottom: -10 } as DOMRectReadOnly, - rootBounds: { top: 0 } as DOMRectReadOnly, - } as IntersectionObserverEntry; - - // This relies on the LAST created observer's callback being captured. - expect(observerCallback).toBeDefined(); - // @ts-ignore - observerCallback([entry], {} as IntersectionObserver); + // Mock getBoundingClientRect + const mainContent = document.getElementById('main-content'); + if (mainContent) { + mainContent.getBoundingClientRect = vi.fn(() => ({ + top: 0, bottom: 500, height: 500, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { } + })); + } + + if (itemEl) { + itemEl.getBoundingClientRect = vi.fn(() => ({ + top: -50, bottom: 50, height: 100, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { } + })); + } + + // Trigger scroll + mainContent?.dispatchEvent(new Event('scroll')); + + // Wait for throttle (250ms) + await new Promise(resolve => setTimeout(resolve, 300)); expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/123'), expect.objectContaining({ method: 'PUT', diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index c310144..a8606e3 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -276,24 +276,40 @@ export function renderItems() { observer.observe(sentinel); } - // Setup item observer for marking read when items scroll past (above viewport) - itemObserver = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (!entry.isIntersecting && entry.boundingClientRect.bottom < (entry.rootBounds?.top ?? 0)) { - const target = entry.target as HTMLElement; - const id = parseInt(target.getAttribute('data-id') || '0'); - if (id) { - const item = store.items.find(i => i._id === id); - if (item && !item.read) { - updateItem(id, { read: true }); - itemObserver?.unobserve(target); - } - } + // Scroll listener for reading items + // We attach this to the scrollable container: #main-content + if (scrollRoot) { + let timeoutId: number | null = null; + const onScroll = () => { + if (timeoutId === null) { + timeoutId = window.setTimeout(() => { + const containerRect = scrollRoot.getBoundingClientRect(); + + store.items.forEach((item) => { + if (item.read) return; + + const el = document.querySelector(`.feed-item[data-id="${item._id}"]`); + if (el) { + const rect = el.getBoundingClientRect(); + // Mark as read if the top of the item is above the top of the container + if (rect.top < containerRect.top) { + updateItem(item._id, { read: true }); + } + } + }); + timeoutId = null; + }, 250); } - }); - }, { root: scrollRoot, threshold: 0 }); - - contentArea.querySelectorAll('.feed-item').forEach(el => itemObserver!.observe(el)); + }; + // Remove existing listener if any (simplistic approach, ideally we track and remove) + // Since renderItems is called multiple times, we might be adding multiple listeners? + // attachLayoutListeners is called once, but renderItems is called on updates. + // We should probably attaching the scroll listener in the layout setup, NOT here. + // But we need access to 'items' which is in store. + // Let's attach it here but be careful. + // Actually, attaching to 'onscroll' property handles replacement automatically. + scrollRoot.onscroll = onScroll; + } } export function renderSettings() { -- cgit v1.2.3