feat: add viewer preference for auto-marking articles as read and implement click/scroll-based read detection logic

This commit is contained in:
2026-08-24 21:25:32 -07:00
parent 3742dbdce4
commit 7a51931386
4 changed files with 112 additions and 122 deletions
+10 -2
View File
@@ -121,11 +121,19 @@ export const App: React.FC = () => {
search: filter.search, search: filter.search,
limit: 100, limit: 100,
}); });
setEntries(res.entries); setEntries((prev) => {
if (filter.view === 'inbox' && selectedEntryId) {
const currentActive = prev.find((e) => e.id === selectedEntryId);
if (currentActive && !res.entries.some((e) => e.id === selectedEntryId)) {
return [currentActive, ...res.entries];
}
}
return res.entries;
});
} catch (err) { } catch (err) {
console.error('Failed to load entries:', err); console.error('Failed to load entries:', err);
} }
}, [currentUser, filter]); }, [currentUser, filter, selectedEntryId]);
// Initial load // Initial load
useEffect(() => { useEffect(() => {
+61 -2
View File
@@ -19,6 +19,7 @@ import {
X, X,
Clock, Clock,
Timer, Timer,
Eye,
} from 'lucide-react'; } from 'lucide-react';
interface SettingsViewProps { interface SettingsViewProps {
@@ -38,7 +39,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
onRefreshData, onRefreshData,
onOpenAddFeed, onOpenAddFeed,
}) => { }) => {
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'opml' | 'account'>('feeds'); const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'viewer' | 'opml' | 'account'>('feeds');
const [autoReadBottom, setAutoReadBottom] = useState<boolean>(() => {
return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
});
const [feedSearch, setFeedSearch] = useState(''); const [feedSearch, setFeedSearch] = useState('');
const [newCatTitle, setNewCatTitle] = useState(''); const [newCatTitle, setNewCatTitle] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -191,6 +195,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
<span>Categories ({categories.length})</span> <span>Categories ({categories.length})</span>
</button> </button>
<button
className={`settings-nav-item ${activeTab === 'viewer' ? 'active' : ''}`}
onClick={() => setActiveTab('viewer')}
>
<Eye size={15} />
<span>Viewer</span>
</button>
<button <button
className={`settings-nav-item ${activeTab === 'opml' ? 'active' : ''}`} className={`settings-nav-item ${activeTab === 'opml' ? 'active' : ''}`}
onClick={() => setActiveTab('opml')} onClick={() => setActiveTab('opml')}
@@ -463,7 +475,54 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
</div> </div>
)} )}
{/* 3. OPML Tab */} {/* 3. Viewer Preferences Tab */}
{activeTab === 'viewer' && (
<div>
<div style={{ marginBottom: '1.25rem' }}>
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
Viewer & Reading Preferences
</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Configure reading behavior, auto-read triggers, and article view options.
</p>
</div>
<div className="settings-card">
<h3 className="settings-card-title">Auto-Mark as Read</h3>
<p className="settings-card-subtitle">
Control how articles are automatically marked as read during your reading session.
</p>
<div style={{ marginTop: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoReadBottom}
onChange={(e) => {
const val = e.target.checked;
setAutoReadBottom(val);
localStorage.setItem('rsspipe_auto_read_bottom', val ? 'true' : 'false');
setMessage(`Updated viewer preferences: auto-read at bottom ${val ? 'enabled' : 'disabled'}.`);
setTimeout(() => setMessage(null), 3000);
}}
style={{ marginTop: '0.2rem', width: 16, height: 16, accentColor: 'var(--accent)' }}
/>
<div>
<div style={{ fontSize: '0.88rem', fontWeight: 600, color: 'var(--text-main)' }}>
Auto mark short articles (&lt; 1 page) as read after 2 seconds
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: '0.25rem', lineHeight: 1.4 }}>
• <strong>Short articles (&lt; 1 page)</strong>: Since the content is non-scrollable and fully visible, automatically marks as read after 2 seconds of viewing.<br />
• <strong>Long articles (&ge; 1 page)</strong>: Any active scroll on the content pane immediately marks the post as read.
</div>
</div>
</label>
</div>
</div>
</div>
)}
{/* 4. OPML Migration Tab */}
{activeTab === 'opml' && ( {activeTab === 'opml' && (
<div> <div>
<div style={{ marginBottom: '1.25rem' }}> <div style={{ marginBottom: '1.25rem' }}>
+36 -49
View File
@@ -43,10 +43,10 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
}) => { }) => {
const cardRefs = useRef<(HTMLDivElement | null)[]>([]); const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const readerPaneRef = useRef<HTMLDivElement>(null); const readerPaneRef = useRef<HTMLDivElement>(null);
const endSentinelRef = useRef<HTMLDivElement>(null);
const selectedEntry = entries[selectedIndex] || entries[0]; const selectedEntry = entries[selectedIndex] || entries[0];
// Auto-scroll list to active card
useEffect(() => { useEffect(() => {
if (selectedIndex >= 0 && selectedIndex < entries.length) { if (selectedIndex >= 0 && selectedIndex < entries.length) {
const el = cardRefs.current[selectedIndex]; const el = cardRefs.current[selectedIndex];
@@ -56,68 +56,52 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
} }
}, [selectedIndex, entries.length]); }, [selectedIndex, entries.length]);
// Robust Auto-Read Detection (Dwell Time for short posts + IntersectionObserver for end of post + Scroll Progress) // When switching selected article, reset reader scroll position to top and handle 2s timer for short content
useEffect(() => { useEffect(() => {
if (readerPaneRef.current) {
readerPaneRef.current.scrollTop = 0;
}
if (!selectedEntry || selectedEntry.is_read) return; if (!selectedEntry || selectedEntry.is_read) return;
let dwellTimer: ReturnType<typeof setTimeout> | null = null; const autoReadBottom = localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
if (!autoReadBottom) return;
// Check if the content fits entirely in the viewport (short article) let timer: ReturnType<typeof setTimeout> | null = null;
const checkViewportFit = () => {
// Small delay to allow DOM layout calculation
const timeout = setTimeout(() => {
if (readerPaneRef.current) { if (readerPaneRef.current) {
const { scrollHeight, clientHeight } = readerPaneRef.current; const { scrollHeight, clientHeight } = readerPaneRef.current;
// If article is short (fits or almost fits), mark as read after 1.5s of reading // Condition 2: If content is < 1 page (not scrollable, already at bottom), mark as read after 2 seconds
if (scrollHeight <= clientHeight + 60) { if (scrollHeight <= clientHeight + 35) {
dwellTimer = setTimeout(() => { timer = setTimeout(() => {
if (selectedEntry && !selectedEntry.is_read) { if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry); onToggleRead(selectedEntry);
} }
}, 1500); }, 2000);
} else {
// For longer articles, if user stays focused for 5 seconds, also auto-read
dwellTimer = setTimeout(() => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
}, 5000);
} }
} }
}; }, 100);
// Small delay to allow DOM render calculation
const timeout = setTimeout(checkViewportFit, 100);
// IntersectionObserver to observe bottom of article
let observer: IntersectionObserver | null = null;
if (endSentinelRef.current && readerPaneRef.current) {
observer = new IntersectionObserver(
(entriesObs) => {
entriesObs.forEach((entryObs) => {
if (entryObs.isIntersecting && selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
});
},
{
root: readerPaneRef.current,
threshold: 0.1,
}
);
observer.observe(endSentinelRef.current);
}
return () => { return () => {
clearTimeout(timeout); clearTimeout(timeout);
if (dwellTimer) clearTimeout(dwellTimer); if (timer) clearTimeout(timer);
if (observer) observer.disconnect();
}; };
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]); }, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
// Reading Detection 1: Any active scroll on content >= 1 page immediately marks as read
const handleReaderScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleReaderScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (!selectedEntry || selectedEntry.is_read) return; if (!selectedEntry || selectedEntry.is_read) return;
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; const { scrollTop } = e.currentTarget;
const scrollProgress = (scrollTop + clientHeight) / scrollHeight; if (scrollTop > 0) {
if (scrollProgress >= 0.85) { onToggleRead(selectedEntry);
}
};
// Reading Detection 2: Clicking anywhere inside the content canvas marks as read
const handleContentPaneClick = () => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry); onToggleRead(selectedEntry);
} }
}; };
@@ -213,11 +197,17 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
</div> </div>
{/* Pane 3: Distraction-Free Reading Canvas */} {/* Pane 3: Distraction-Free Reading Canvas */}
<div className="reader-pane" ref={readerPaneRef} onScroll={handleReaderScroll}> <div
className="reader-pane"
ref={readerPaneRef}
onScroll={handleReaderScroll}
onClick={handleContentPaneClick}
tabIndex={0}
>
{selectedEntry ? ( {selectedEntry ? (
<> <>
{/* Top Toolbar */} {/* Top Toolbar */}
<div className="reader-toolbar"> <div className="reader-toolbar" onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<button <button
className="btn-icon" className="btn-icon"
@@ -309,9 +299,6 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
__html: selectedEntry.content || selectedEntry.raw_content || '', __html: selectedEntry.content || selectedEntry.raw_content || '',
}} }}
/> />
{/* End of article sentinel for auto-read observer */}
<div ref={endSentinelRef} style={{ height: 1, marginTop: '2rem' }} />
</div> </div>
</> </>
) : ( ) : (
+5 -69
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Category, FeedEntry, FilterState } from '../types'; import { Category, FeedEntry, FilterState } from '../types';
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime } from '../utils'; import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
import { import {
@@ -34,73 +34,12 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
}) => { }) => {
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null); const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
const readerScrollRef = useRef<HTMLDivElement>(null); const readerScrollRef = useRef<HTMLDivElement>(null);
const endSentinelRef = useRef<HTMLDivElement>(null);
// When card is clicked, open in full-screen reader view and immediately mark as read
const handleCardClick = (entry: FeedEntry) => { const handleCardClick = (entry: FeedEntry) => {
setSelectedEntry(entry); setSelectedEntry(entry);
}; if (!entry.is_read) {
onToggleRead(entry);
// Robust Auto-Read Detection in Waterfall Full-Screen Reader
useEffect(() => {
if (!selectedEntry || selectedEntry.is_read) return;
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
const checkViewportFit = () => {
if (readerScrollRef.current) {
const { scrollHeight, clientHeight } = readerScrollRef.current;
// Short article: auto mark as read after 1.5s
if (scrollHeight <= clientHeight + 60) {
dwellTimer = setTimeout(() => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
}, 1500);
} else {
// Longer article: auto mark read after 5s active viewing
dwellTimer = setTimeout(() => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
}, 5000);
}
}
};
const timeout = setTimeout(checkViewportFit, 100);
// IntersectionObserver on bottom sentinel
let observer: IntersectionObserver | null = null;
if (endSentinelRef.current && readerScrollRef.current) {
observer = new IntersectionObserver(
(entriesObs) => {
entriesObs.forEach((entryObs) => {
if (entryObs.isIntersecting && selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
});
},
{
root: readerScrollRef.current,
threshold: 0.1,
}
);
observer.observe(endSentinelRef.current);
}
return () => {
clearTimeout(timeout);
if (dwellTimer) clearTimeout(dwellTimer);
if (observer) observer.disconnect();
};
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
const handlePostScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (!selectedEntry || selectedEntry.is_read) return;
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
if (scrollProgress >= 0.85) {
onToggleRead(selectedEntry);
} }
}; };
@@ -342,7 +281,7 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
</div> </div>
{/* Reading Body */} {/* Reading Body */}
<div className="mobile-post-reader-content" ref={readerScrollRef} onScroll={handlePostScroll}> <div className="mobile-post-reader-content" ref={readerScrollRef}>
<div className="reader-content-canvas" style={{ padding: 0 }}> <div className="reader-content-canvas" style={{ padding: 0 }}>
<div className="reader-source-bar"> <div className="reader-source-bar">
<span className="tag-badge">{selectedEntry.feed_title}</span> <span className="tag-badge">{selectedEntry.feed_title}</span>
@@ -379,9 +318,6 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
__html: selectedEntry.content || selectedEntry.raw_content || '', __html: selectedEntry.content || selectedEntry.raw_content || '',
}} }}
/> />
{/* End of article sentinel for auto-read observer */}
<div ref={endSentinelRef} style={{ height: 1, marginTop: '2rem' }} />
</div> </div>
</div> </div>
</div> </div>