diff options
Diffstat (limited to 'frontend-vanilla')
| -rw-r--r-- | frontend-vanilla/src/main.test.ts | 39 | ||||
| -rw-r--r-- | frontend-vanilla/src/main.ts | 26 |
2 files changed, 64 insertions, 1 deletions
diff --git a/frontend-vanilla/src/main.test.ts b/frontend-vanilla/src/main.test.ts index 7cae34b..a8b6969 100644 --- a/frontend-vanilla/src/main.test.ts +++ b/frontend-vanilla/src/main.test.ts @@ -22,7 +22,11 @@ vi.mock('./api', () => ({ })); // Mock IntersectionObserver as a constructor +let observerCallback: IntersectionObserverCallback; class MockIntersectionObserver { + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); @@ -254,4 +258,39 @@ describe('main application logic', () => { toggleBtn.click(); expect(store.sidebarVisible).toBe(!initialVisible); }); + + it('should mark item as read when scrolled into view', () => { + const mockItem = { + _id: 123, + title: 'Scroll Test Item', + read: false, + url: 'http://example.com', + publish_date: '2023-01-01' + } as any; + + store.setItems([mockItem]); + renderLayout(); + renderItems(); + + const itemEl = document.querySelector(`.feed-item[data-id="123"]`); + expect(itemEl).not.toBeNull(); + + vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response); + + // Simulate intersection + const entry = { + target: itemEl, + isIntersecting: true + } as IntersectionObserverEntry; + + // This relies on the LAST created observer's callback being captured. + expect(observerCallback).toBeDefined(); + // @ts-ignore + observerCallback([entry], {} as IntersectionObserver); + + expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/123'), expect.objectContaining({ + method: 'PUT', + body: expect.stringContaining('"read":true') + })); + }); }); diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 93bee63..b167a18 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -18,6 +18,7 @@ let activeItemId: number | null = null; // Cache elements (initialized in renderLayout) let appEl: HTMLDivElement | null = null; +let itemObserver: IntersectionObserver | null = null; // Initial Layout (v2-style 2-pane) export function renderLayout() { @@ -216,6 +217,11 @@ export function renderFilters() { export function renderItems() { const { items, loading } = store; + + if (itemObserver) { + itemObserver.disconnect(); + itemObserver = null; + } const contentArea = document.getElementById('content-area'); if (!contentArea || router.getCurrentRoute().path === '/settings') return; @@ -246,6 +252,25 @@ export function renderItems() { }, { threshold: 0.1 }); observer.observe(sentinel); } + + // Setup item observer for marking read + itemObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + 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); + } + } + } + }); + }, { threshold: 0.5 }); + + contentArea.querySelectorAll('.feed-item').forEach(el => itemObserver!.observe(el)); } export function renderSettings() { @@ -623,7 +648,6 @@ window.app = { }; // Start -// Start export async function init() { const authRes = await apiFetch('/api/auth'); if (!authRes || authRes.status === 401) { |
