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,
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) {
console.error('Failed to load entries:', err);
}
}, [currentUser, filter]);
}, [currentUser, filter, selectedEntryId]);
// Initial load
useEffect(() => {
+61 -2
View File
@@ -19,6 +19,7 @@ import {
X,
Clock,
Timer,
Eye,
} from 'lucide-react';
interface SettingsViewProps {
@@ -38,7 +39,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
onRefreshData,
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 [newCatTitle, setNewCatTitle] = useState('');
const [loading, setLoading] = useState(false);
@@ -191,6 +195,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
<span>Categories ({categories.length})</span>
</button>
<button
className={`settings-nav-item ${activeTab === 'viewer' ? 'active' : ''}`}
onClick={() => setActiveTab('viewer')}
>
<Eye size={15} />
<span>Viewer</span>
</button>
<button
className={`settings-nav-item ${activeTab === 'opml' ? 'active' : ''}`}
onClick={() => setActiveTab('opml')}
@@ -463,7 +475,54 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
</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' && (
<div>
<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 readerPaneRef = useRef<HTMLDivElement>(null);
const endSentinelRef = useRef<HTMLDivElement>(null);
const selectedEntry = entries[selectedIndex] || entries[0];
// Auto-scroll list to active card
useEffect(() => {
if (selectedIndex >= 0 && selectedIndex < entries.length) {
const el = cardRefs.current[selectedIndex];
@@ -56,68 +56,52 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
}
}, [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(() => {
if (readerPaneRef.current) {
readerPaneRef.current.scrollTop = 0;
}
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)
const checkViewportFit = () => {
let timer: ReturnType<typeof setTimeout> | null = null;
// Small delay to allow DOM layout calculation
const timeout = setTimeout(() => {
if (readerPaneRef.current) {
const { scrollHeight, clientHeight } = readerPaneRef.current;
// If article is short (fits or almost fits), mark as read after 1.5s of reading
if (scrollHeight <= clientHeight + 60) {
dwellTimer = setTimeout(() => {
// Condition 2: If content is < 1 page (not scrollable, already at bottom), mark as read after 2 seconds
if (scrollHeight <= clientHeight + 35) {
timer = setTimeout(() => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
}, 1500);
} else {
// For longer articles, if user stays focused for 5 seconds, also auto-read
dwellTimer = setTimeout(() => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
}, 5000);
}, 2000);
}
}
};
// 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);
}
}, 100);
return () => {
clearTimeout(timeout);
if (dwellTimer) clearTimeout(dwellTimer);
if (observer) observer.disconnect();
if (timer) clearTimeout(timer);
};
}, [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>) => {
if (!selectedEntry || selectedEntry.is_read) return;
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
if (scrollProgress >= 0.85) {
const { scrollTop } = e.currentTarget;
if (scrollTop > 0) {
onToggleRead(selectedEntry);
}
};
// Reading Detection 2: Clicking anywhere inside the content canvas marks as read
const handleContentPaneClick = () => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
};
@@ -213,11 +197,17 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
</div>
{/* 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 ? (
<>
{/* Top Toolbar */}
<div className="reader-toolbar">
<div className="reader-toolbar" onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<button
className="btn-icon"
@@ -309,9 +299,6 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
__html: selectedEntry.content || selectedEntry.raw_content || '',
}}
/>
{/* End of article sentinel for auto-read observer */}
<div ref={endSentinelRef} style={{ height: 1, marginTop: '2rem' }} />
</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 { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
import {
@@ -34,73 +34,12 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
}) => {
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(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) => {
setSelectedEntry(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);
if (!entry.is_read) {
onToggleRead(entry);
}
};
@@ -342,7 +281,7 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
</div>
{/* 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-source-bar">
<span className="tag-badge">{selectedEntry.feed_title}</span>
@@ -379,9 +318,6 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
__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>