aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/FeedItems.test.tsx62
-rw-r--r--frontend/src/components/FeedItems.tsx75
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(() => {