feat: add viewer preference for auto-marking articles as read and implement click/scroll-based read detection logic
This commit is contained in:
@@ -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 (< 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 (< 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 (≥ 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' }}>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user