diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/components/FeedItems.test.tsx | 62 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.tsx | 75 |
2 files changed, 87 insertions, 50 deletions
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx index fc95948..ad8bf4f 100644 --- a/frontend/src/components/FeedItems.test.tsx +++ b/frontend/src/components/FeedItems.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import '@testing-library/jest-dom'; import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import FeedItems from './FeedItems'; describe('FeedItems Component', () => { @@ -126,6 +126,11 @@ describe('FeedItems Component', () => { }); }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it('marks items as read when scrolled past', async () => { const mockItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false }]; vi.mocked(global.fetch).mockResolvedValue({ @@ -133,44 +138,53 @@ describe('FeedItems Component', () => { json: async () => mockItems, } as Response); - const observerCallbacks: IntersectionObserverCallback[] = []; - class MockIntersectionObserver { - constructor(callback: IntersectionObserverCallback) { - observerCallbacks.push(callback); + // Mock getBoundingClientRect + const getBoundingClientRectMock = vi.spyOn(Element.prototype, 'getBoundingClientRect'); + getBoundingClientRectMock.mockImplementation(function (this: Element) { + if (this.classList && this.classList.contains('dashboard-main')) { + return { + top: 0, bottom: 500, height: 500, left: 0, right: 1000, width: 1000, x: 0, y: 0, + toJSON: () => { } + } as DOMRect; } - observe = vi.fn(); - unobserve = vi.fn(); - disconnect = vi.fn(); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - window.IntersectionObserver = MockIntersectionObserver as any; + if (this.id && this.id.startsWith('item-')) { + // Item top is -50 (above container top 0) + return { + top: -50, bottom: 50, height: 100, left: 0, right: 1000, width: 1000, x: 0, y: 0, + toJSON: () => { } + } as DOMRect; + } + return { + top: 0, bottom: 0, height: 0, left: 0, right: 0, width: 0, x: 0, y: 0, + toJSON: () => { } + } as DOMRect; + }); render( <MemoryRouter> - <FeedItems /> + <div className="dashboard-main"> + <FeedItems /> + </div> </MemoryRouter> ); + // Initial load fetch await waitFor(() => { expect(screen.getByText('Item 1')).toBeVisible(); }); - // Simulate item leaving viewport - const entry = { - isIntersecting: false, - boundingClientRect: { top: -50 } as DOMRectReadOnly, - target: { getAttribute: () => '0' } as unknown as Element, // data-index="0" - intersectionRatio: 0, - time: 0, - rootBounds: null, - intersectionRect: {} as DOMRectReadOnly, - } as IntersectionObserverEntry; + // Trigger scroll + const container = document.querySelector('.dashboard-main'); + expect(container).not.toBeNull(); act(() => { - // Trigger ALL registered observers - observerCallbacks.forEach(cb => cb([entry], {} as IntersectionObserver)); + // Dispatch scroll event + fireEvent.scroll(container!); }); + // Wait for throttle (500ms) + buffer + await new Promise(r => setTimeout(r, 600)); + await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( '/api/item/101', diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx index ea5d8fd..e2df011 100644 --- a/frontend/src/components/FeedItems.tsx +++ b/frontend/src/components/FeedItems.tsx @@ -198,39 +198,62 @@ export default function FeedItems() { }, [markAsRead, scrollToItem, toggleStar, fetchItems]); - // Stable Observer - const observerRef = useRef<IntersectionObserver | null>(null); + // Scroll listener to mark items as read const sentinelObserverRef = useRef<IntersectionObserver | null>(null); - useEffect(() => { - if (observerRef.current) observerRef.current.disconnect(); - - observerRef.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting && entry.boundingClientRect.top < 0) { - const index = Number(entry.target.getAttribute('data-index')); - const currentItems = itemsRef.current; - if (!isNaN(index) && index >= 0 && index < currentItems.length) { - const item = currentItems[index]; - if (!item.read) { - markAsRead(item); - } - } - } - }); - }, - { root: null, threshold: 0 } - ); + const checkReadStatus = useCallback(() => { + const container = document.querySelector('.dashboard-main'); + if (!container) return; + const containerRect = container.getBoundingClientRect(); const currentItems = itemsRef.current; - currentItems.forEach((_, index) => { + + currentItems.forEach((item, index) => { + if (item.read) return; + const el = document.getElementById(`item-${index}`); - if (el) observerRef.current?.observe(el); + if (!el) return; + + 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) { + markAsRead(item); + } }); + }, [markAsRead]); + + // Setup scroll listener + useEffect(() => { + const container = document.querySelector('.dashboard-main'); + if (!container) return; + + let timeoutId: number | null = null; + const onScroll = () => { + if (timeoutId === null) { + timeoutId = window.setTimeout(() => { + checkReadStatus(); + timeoutId = null; + }, 250); + } + }; + + container.addEventListener('scroll', onScroll); + + // Initial check + checkReadStatus(); + + return () => { + if (timeoutId) clearTimeout(timeoutId); + container.removeEventListener('scroll', onScroll); + }; + }, [checkReadStatus]); + + // Re-check when items change (e.g. initial load or load more) + useEffect(() => { + checkReadStatus(); + }, [items, checkReadStatus]); - return () => observerRef.current?.disconnect(); - }, [items.length, markAsRead]); // Only re-setup if item count changes useEffect(() => { |
