diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 21:07:05 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 21:07:05 +0000 |
| commit | d7c05609692153670dc136082da4e0566d7838a6 (patch) | |
| tree | ae0a4b5ca3a04eb61cba73fa8a3c3c873faf984f /app/src/main/java/org/terst/feedreader/ui | |
| parent | 8e06fc84c0b540f8ba87afa19d4c08eab7af80d9 (diff) | |
Remove reader-mode heuristics entirely; explicit per-feed opt-in only
Reader mode was still switching kottke articles even after the v3
opt-out and the "un-hide only if thin" fallback -- both were still
heuristic (content-length-based), and kottke's posts are routinely short
by design, not paywalled, so the heuristics kept firing on ordinary
content. Removed the auto-extract-on-thin-content logic entirely: reader
mode now NEVER turns on by itself, under any condition.
- FeedEntity.readerModeDisabled -> readerModeEnabled (opt-in, default off
for every feed -- schema v4, full table rebuild since Room's migration
validation requires an exact column match and a bare ADD COLUMN left
the old column orphaned; reproduced live as a crash, "Migration didn't
properly handle: feeds").
- The reader's toggle icon now sets reader mode for the article's WHOLE
FEED, persists it immediately, and confirms via snackbar ("Reader mode
enabled/disabled for X") -- both in the reader screen and the Feeds
list's own toggle. This is the only way reader mode ever turns on.
- ReaderExtractor: replaced the "un-hide everything if thin" paywall
fallback (which is what pulled kottke's site footer into the result --
confirmed live, twice) with one that only un-hides elements whose
class/id actually names them as paywall/premium/subscriber content
(West Hawaii Today's hidden div is literally class="paywall
paywall_<id>"). This turned out not to be the kottke bug's actual cause
either -- see below -- but it's a real, independent correctness
improvement over the previous blanket version, so it stays.
- Also added: noscript-wrapped lazy-load image fallback (a common
pattern pairs a placeholder <img> with a real one inside <noscript>,
which our JS-enabled extraction WebView never parses into DOM per the
HTML spec) and lazy data-src/srcset resolution generally.
Root cause of the kottke footer bug, found by fetching and inspecting
the actual page structure rather than guessing again: it's not a
CSS-hidden-element issue at all. kottke's real post content lives in a
plain, undecorated <div class="post"> that's a sibling (not ancestor) of
an ads container and a "Socials & More" footer widget, all under one
shared <div class="grid-container">. Because kottke's posts are so
short, Readability's generic paragraph-density scoring doesn't find
enough signal in .post alone and climbs to a broader container that
happens to include the neighboring footer. No un-hide logic is involved
at any point -- this reproduces identically on the plain, unmodified
Readability pass. This is a genuine limit of heuristic content
extraction on very short-form posts, not something to keep patching with
more heuristics. Reset kottke's setting back to off (its correct
default) after testing.
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui')
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt | 23 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt | 87 |
2 files changed, 64 insertions, 46 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt index f8ea504..47f51fc 100644 --- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -73,6 +75,7 @@ fun FeedListScreen( val olderUnreadCount by remember { db.articleDao().observeOlderUnreadCount(cutoff) }.collectAsState(initial = 0) var menuExpanded by remember { mutableStateOf(false) } var pendingDelete by remember { mutableStateOf<FeedEntity?>(null) } + val snackbarHostState = remember { SnackbarHostState() } pendingDelete?.let { feed -> AlertDialog( @@ -121,7 +124,8 @@ fun FeedListScreen( FloatingActionButton(onClick = onAddFeed) { Icon(Icons.Default.Add, contentDescription = "Add feed") } - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { item { @@ -152,7 +156,14 @@ fun FeedListScreen( onClick = { onOpenFeed(feed.id) }, onDelete = { pendingDelete = feed }, onToggleReaderMode = { - scope.launch { db.feedDao().update(feed.copy(readerModeDisabled = !feed.readerModeDisabled)) } + val newEnabled = !feed.readerModeEnabled + scope.launch { + db.feedDao().update(feed.copy(readerModeEnabled = newEnabled)) + val label = feed.title.ifBlank { "this feed" } + snackbarHostState.showSnackbar( + if (newEnabled) "Reader mode enabled for $label" else "Reader mode disabled for $label" + ) + } } ) HorizontalDivider() @@ -184,11 +195,11 @@ private fun FeedRow( } IconButton(onClick = onToggleReaderMode) { Icon( - if (feed.readerModeDisabled) Icons.Outlined.Article else Icons.Filled.Article, - contentDescription = if (feed.readerModeDisabled) - "Reader mode disabled for this feed -- tap to re-enable" - else + if (feed.readerModeEnabled) Icons.Filled.Article else Icons.Outlined.Article, + contentDescription = if (feed.readerModeEnabled) "Reader mode enabled for this feed -- tap to disable" + else + "Reader mode disabled for this feed -- tap to enable" ) } IconButton(onClick = onDelete) { diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt index 4becea6..806926c 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -59,10 +59,6 @@ import org.terst.feedreader.stash.StashClient import org.terst.feedreader.stash.StashSaveResult import java.net.URLEncoder -// Below this length (of the plain feed-provided HTML), assume it's a teaser/summary, -// not the real article body, and auto-fall-back to on-device Readability extraction. -private const val THIN_CONTENT_THRESHOLD = 500 - /** * Swipeable wrapper: snapshots the article-id order of the list this reader * was opened from (Unread/Saved/a specific feed) ONCE at entry, then pages @@ -191,44 +187,53 @@ private fun ReaderContent( feedContent = loaded.feedContentHtml readerContent = loaded.extractedContent - val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0 - if (loadedFeed?.readerModeDisabled != true && readerContent == null && plainLength < THIN_CONTENT_THRESHOLD) { - // Feed content is too thin to be the real article -- auto-extract, since in this - // case reader mode isn't really an alternative view, it's the only real content. - isExtracting = true - val extracted = ReaderExtractor.extract(context, loaded.link) - isExtracting = false - extracted?.content?.let { - readerContent = it - db.articleDao().setExtractedContent(articleId, it) - } + // Reader mode is NEVER heuristic/automatic (no more "content looks thin, switch + // for you") -- it strictly follows the feed's persisted, explicitly-set opt-in. + // Once a feed has it on, articles from it default to the extracted view and + // auto-extract on open (that's the point of persisting it per-feed: no re-tapping + // per article), but nothing here ever turns it on by itself. + if (loadedFeed?.readerModeEnabled == true) { isReaderMode = true + if (readerContent == null) { + isExtracting = true + val extracted = ReaderExtractor.extract(context, loaded.link) + isExtracting = false + extracted?.content?.let { + readerContent = it + db.articleDao().setExtractedContent(articleId, it) + } + } } } - fun toggleReaderMode() { + // The toggle now sets reader mode for the ARTICLE'S FEED, persisted immediately, with + // a confirmation snackbar -- not a per-article, this-session-only view flip. Tapping it + // is the one and only way reader mode ever turns on. + fun toggleFeedReaderMode() { + val f = feed ?: return val a = article ?: return - if (feed?.readerModeDisabled == true) return - if (isReaderMode) { - isReaderMode = false - return - } - if (readerContent != null) { - isReaderMode = true - return - } + val newEnabled = !f.readerModeEnabled + feed = f.copy(readerModeEnabled = newEnabled) scope.launch { - isExtracting = true - val extracted = ReaderExtractor.extract(context, a.link) - isExtracting = false - val content = extracted?.content - if (content != null) { - readerContent = content - db.articleDao().setExtractedContent(articleId, content) + db.feedDao().update(f.copy(readerModeEnabled = newEnabled)) + val feedLabel = f.title.ifBlank { "this feed" } + if (newEnabled) { + if (readerContent == null) { + isExtracting = true + val extracted = ReaderExtractor.extract(context, a.link) + isExtracting = false + val content = extracted?.content + if (content != null) { + readerContent = content + db.articleDao().setExtractedContent(articleId, content) + } + } isReaderMode = true + snackbarHostState.showSnackbar("Reader mode enabled for $feedLabel") } else { - snackbarHostState.showSnackbar("Couldn't extract a reader view for this article") + isReaderMode = false + snackbarHostState.showSnackbar("Reader mode disabled for $feedLabel") } } } @@ -257,14 +262,16 @@ private fun ReaderContent( }, actions = { val currentArticle = article - // Only offer the toggle when there's an actual feed-provided alternative to - // switch back to (if we already had to auto-extract for thin feed content, - // reader mode IS the only content) and the feed hasn't opted out of it. - if (feedContent != null && feed?.readerModeDisabled != true) { - IconButton(enabled = !isExtracting, onClick = { toggleReaderMode() }) { + // Sets reader mode for the whole feed (persisted), not just this article -- + // shown whenever the article belongs to an actual subscribed feed. + if (feed != null) { + IconButton(enabled = !isExtracting, onClick = { toggleFeedReaderMode() }) { Icon( - if (isReaderMode) Icons.Filled.Article else Icons.Outlined.Article, - contentDescription = "Toggle reader view" + if (feed?.readerModeEnabled == true) Icons.Filled.Article else Icons.Outlined.Article, + contentDescription = if (feed?.readerModeEnabled == true) + "Reader mode enabled for this feed -- tap to disable" + else + "Reader mode disabled for this feed -- tap to enable" ) } } |
