aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 11:45:02 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 11:45:02 -0800
commitf8fcc0be57fa7f471ffd22d4b9559cb6d0ff20bf (patch)
treeca2d51b24b0908e1c857618f114ce6fa5593041f /frontend/src
parentfc2bc854f4e3bae3503d5000f1fbc414bfa7e0cc (diff)
downloadneko-f8fcc0be57fa7f471ffd22d4b9559cb6d0ff20bf.tar.gz
neko-f8fcc0be57fa7f471ffd22d4b9559cb6d0ff20bf.tar.bz2
neko-f8fcc0be57fa7f471ffd22d4b9559cb6d0ff20bf.zip
Implement infinite scroll for feed items view (NK-5ocxgm)
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/components/FeedItems.css12
-rw-r--r--frontend/src/components/FeedItems.test.tsx55
-rw-r--r--frontend/src/components/FeedItems.tsx46
3 files changed, 106 insertions, 7 deletions
diff --git a/frontend/src/components/FeedItems.css b/frontend/src/components/FeedItems.css
index f271b34..54c882a 100644
--- a/frontend/src/components/FeedItems.css
+++ b/frontend/src/components/FeedItems.css
@@ -12,10 +12,20 @@
list-style: none;
padding: 0;
}
+
.selected-item-container {
border-left: 4px solid #007bff;
background-color: #f8f9fa;
padding-left: 0.5rem;
- margin-left: -0.5rem; /* Compensate for padding/border to keep alignment */
+ margin-left: -0.5rem;
+ /* Compensate for padding/border to keep alignment */
transition: background-color 0.2s;
}
+
+.loading-more {
+ padding: 2rem;
+ text-align: center;
+ color: #888;
+ font-size: 0.9rem;
+ min-height: 50px;
+} \ No newline at end of file
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx
index 00118fa..ea68a7c 100644
--- a/frontend/src/components/FeedItems.test.tsx
+++ b/frontend/src/components/FeedItems.test.tsx
@@ -53,8 +53,6 @@ describe('FeedItems Component', () => {
await waitFor(() => {
expect(screen.getByText('Item One')).toBeInTheDocument();
- // Title should now be "Feed Items" based on logic
- expect(screen.getByText('Feed Items')).toBeInTheDocument();
});
const params = new URLSearchParams();
@@ -171,4 +169,57 @@ describe('FeedItems Component', () => {
}));
});
});
+
+ it('loads more items when sentinel becomes visible', async () => {
+ const initialItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: true, starred: false }];
+ const moreItems = [{ _id: 100, title: 'Item 0', url: 'u0', read: true, starred: false }];
+
+ (global.fetch as any)
+ .mockResolvedValueOnce({ ok: true, json: async () => initialItems })
+ .mockResolvedValueOnce({ ok: true, json: async () => moreItems });
+
+ let observerCallback: IntersectionObserverCallback = () => { };
+ class MockIntersectionObserver {
+ constructor(callback: IntersectionObserverCallback) {
+ observerCallback = callback;
+ }
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+ }
+ window.IntersectionObserver = MockIntersectionObserver as any;
+
+ render(
+ <MemoryRouter>
+ <FeedItems />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Item 1')).toBeInTheDocument();
+ });
+
+ // Simulate sentinel becoming visible
+ const entry = {
+ isIntersecting: true,
+ target: { id: 'load-more-sentinel' } as unknown as Element,
+ boundingClientRect: {} as DOMRectReadOnly,
+ intersectionRatio: 1,
+ time: 0,
+ rootBounds: null,
+ intersectionRect: {} as DOMRectReadOnly,
+ } as IntersectionObserverEntry;
+
+ act(() => {
+ observerCallback([entry], {} as IntersectionObserver);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Item 0')).toBeInTheDocument();
+ const params = new URLSearchParams();
+ params.append('max_id', '101');
+ params.append('read_filter', 'unread');
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
+ });
+ });
});
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx
index aaec2f4..179d5cd 100644
--- a/frontend/src/components/FeedItems.tsx
+++ b/frontend/src/components/FeedItems.tsx
@@ -11,10 +11,17 @@ export default function FeedItems() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState('');
- useEffect(() => {
- setLoading(true);
+ const fetchItems = (maxId?: string) => {
+ if (maxId) {
+ setLoadingMore(true);
+ } else {
+ setLoading(true);
+ setItems([]);
+ }
setError('');
let url = '/api/stream';
@@ -26,6 +33,10 @@ export default function FeedItems() {
params.append('tag', tagName);
}
+ if (maxId) {
+ params.append('max_id', maxId);
+ }
+
// Apply filters
if (filterFn === 'all') {
params.append('read_filter', 'all');
@@ -50,13 +61,24 @@ export default function FeedItems() {
return res.json();
})
.then((data) => {
- setItems(data);
+ if (maxId) {
+ setItems((prev) => [...prev, ...data]);
+ } else {
+ setItems(data);
+ }
+ setHasMore(data.length > 0);
setLoading(false);
+ setLoadingMore(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
+ setLoadingMore(false);
});
+ };
+
+ useEffect(() => {
+ fetchItems();
}, [feedId, tagName, filterFn]);
const [selectedIndex, setSelectedIndex] = useState(-1);
@@ -134,6 +156,14 @@ export default function FeedItems() {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
+ // Infinity scroll sentinel
+ if (entry.target.id === 'load-more-sentinel') {
+ if (entry.isIntersecting && !loadingMore && hasMore && items.length > 0) {
+ fetchItems(String(items[items.length - 1]._id));
+ }
+ return;
+ }
+
// If item is not intersecting and is above the viewport, it's been scrolled past
if (!entry.isIntersecting && entry.boundingClientRect.top < 0) {
const index = Number(entry.target.getAttribute('data-index'));
@@ -154,8 +184,11 @@ export default function FeedItems() {
if (el) observer.observe(el);
});
+ const sentinel = document.getElementById('load-more-sentinel');
+ if (sentinel) observer.observe(sentinel);
+
return () => observer.disconnect();
- }, [items]);
+ }, [items, loadingMore, hasMore]);
if (loading) return <div className="feed-items-loading">Loading items...</div>;
if (error) return <div className="feed-items-error">Error: {error}</div>;
@@ -178,6 +211,11 @@ export default function FeedItems() {
<FeedItem item={item} />
</div>
))}
+ {hasMore && (
+ <div id="load-more-sentinel" className="loading-more">
+ {loadingMore ? 'Loading more...' : ''}
+ </div>
+ )}
</ul>
)}
</div>