From 34b663c6e75a2c360de154418dafb8f616b546a1 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 22 Jul 2026 20:37:41 +0000 Subject: Fix extraction regression on kottke; add Older view, mark-read-from-here - ReaderExtractor: the un-hide-before-extracting fix (for soft paywalls like West Hawaii Today) was unconditional -- it also un-hid normal, legitimately-collapsed chrome (kottke's footer/membership notice), which Readability then folded into the result alongside the real article. Reported live with a screenshot showing the extracted content wrapped in site footer boilerplate. Now tries the normal (hidden-respecting) pass first and only falls back to the un-hide pass if that came back thin (<500 chars), keeping whichever pass actually found more text. Verified live: the same kottke article now stays clean and stable (no delayed switch to the footer version). - New Unread/Older split: the default Unread view is now clamped to the last 72 hours (RECENT_WINDOW_MS); the rest of the unread backlog moved to a new "Older" view (new FeedListScreen row, ArticleDao.observeUnread now takes a cutoff, observeOlderUnread/observeUnreadCount/ observeOlderUnreadCount added). Undated articles are kept in the main Unread view rather than silently dropped from both. Verified live: 150 recent / 1172 older, summing to the prior combined total. - "Mark read from here": long-press an article row to mark it and everything older in that same filtered list as read in one batch (re-added ArticleDao.markReadBatch, this time as an explicit, confirmed action -- not the implicit on-scroll version that was reverted earlier). Verified live: confirmed a 147-article batch: the tapped article and older were marked read, newer ones above it were not. - BACKLOG.md: added a YouTube-embeds note (JS is off in the reader WebView, so embeds currently show broken -- rough shape is a tap-to-play thumbnail rather than turning JS on for third-party content). --- .../org/terst/feedreader/ui/ArticleListScreen.kt | 52 ++++++++++++++++++---- .../java/org/terst/feedreader/ui/FeedListScreen.kt | 15 ++++++- .../java/org/terst/feedreader/ui/MainActivity.kt | 5 +++ .../java/org/terst/feedreader/ui/ReaderScreen.kt | 4 +- 4 files changed, 65 insertions(+), 11 deletions(-) (limited to 'app/src/main/java/org/terst/feedreader/ui') diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt index c337492..4a67a3a 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -1,7 +1,8 @@ package org.terst.feedreader.ui +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -12,6 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.List +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -20,6 +22,7 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -27,6 +30,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -36,6 +40,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleListItem import org.terst.feedreader.data.FilterResult @@ -43,7 +48,7 @@ import org.terst.feedreader.data.FilterRuleEngine import java.text.DateFormat import java.util.Date -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ArticleListScreen( feedId: String, @@ -53,10 +58,13 @@ fun ArticleListScreen( ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } + val scope = rememberCoroutineScope() + val cutoff = remember(feedId) { System.currentTimeMillis() - RECENT_WINDOW_MS } val articleFlow = remember(feedId) { when (feedId) { - UNREAD_FEED_ID -> db.articleDao().observeUnread() + UNREAD_FEED_ID -> db.articleDao().observeUnread(cutoff) + OLDER_FEED_ID -> db.articleDao().observeOlderUnread(cutoff) SAVED_FEED_ID -> db.articleDao().observeSaved() else -> db.articleDao().observeByFeed(feedId) } @@ -68,9 +76,9 @@ fun ArticleListScreen( } }.collectAsState(initial = emptyList()) - // Unread/Saved mix articles from every feed, so each row names its source; a + // Unread/Older/Saved mix articles from every feed, so each row names its source; a // single feed's own list already says so in the top bar, so rows there don't repeat it. - val showFeedName = feedId == UNREAD_FEED_ID || feedId == SAVED_FEED_ID + val showFeedName = feedId == UNREAD_FEED_ID || feedId == OLDER_FEED_ID || feedId == SAVED_FEED_ID var specificFeedTitle by remember(feedId) { mutableStateOf(null) } LaunchedEffect(feedId) { @@ -79,6 +87,30 @@ fun ArticleListScreen( } } + // "Mark read from here": long-press an article to mark it and everything older + // (further down this same filtered/sorted list) as read in one batch, rather than + // opening each one individually. Confirmed rather than instant, since it can affect + // a lot of rows at once and there's no undo. + var pendingMarkReadFrom by remember(feedId) { mutableStateOf(null) } + pendingMarkReadFrom?.let { from -> + val index = visibleArticles.indexOfFirst { it.first.id == from.id } + val toMark = if (index >= 0) visibleArticles.drop(index).map { it.first }.filter { !it.isRead } else emptyList() + AlertDialog( + onDismissRequest = { pendingMarkReadFrom = null }, + title = { Text("Mark read from here?") }, + text = { Text("Marks ${toMark.size} article(s) as read.") }, + confirmButton = { + TextButton(onClick = { + scope.launch { db.articleDao().markReadBatch(toMark.map { it.id }) } + pendingMarkReadFrom = null + }) { Text("Mark read") } + }, + dismissButton = { + TextButton(onClick = { pendingMarkReadFrom = null }) { Text("Cancel") } + } + ) + } + Scaffold( topBar = { TopAppBar( @@ -86,6 +118,7 @@ fun ArticleListScreen( Text( when (feedId) { UNREAD_FEED_ID -> "Unread" + OLDER_FEED_ID -> "Older" SAVED_FEED_ID -> "Saved" else -> specificFeedTitle ?: "Articles" } @@ -113,7 +146,8 @@ fun ArticleListScreen( article = article, highlighted = result == FilterResult.HIGHLIGHTED, showFeedName = showFeedName, - onClick = { onOpenArticle(article.id) } + onClick = { onOpenArticle(article.id) }, + onMarkReadFrom = { pendingMarkReadFrom = article } ) HorizontalDivider() } @@ -121,12 +155,14 @@ fun ArticleListScreen( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ArticleRow( article: ArticleListItem, highlighted: Boolean, showFeedName: Boolean, - onClick: () -> Unit + onClick: () -> Unit, + onMarkReadFrom: () -> Unit ) { val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } val metaText = listOfNotNull(if (showFeedName) article.feedTitle else null, dateText) @@ -156,6 +192,6 @@ private fun ArticleRow( modifier = Modifier .fillMaxWidth() .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) - .clickable { onClick() } + .combinedClickable(onClick = onClick, onLongClick = onMarkReadFrom) ) } 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 5ed5855..f8ea504 100644 --- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -52,6 +52,7 @@ import org.terst.feedreader.work.FeedFetchWorker fun FeedListScreen( onOpenFeed: (String) -> Unit, onOpenUnread: () -> Unit, + onOpenOlder: () -> Unit, onOpenSaved: () -> Unit, onAddFeed: () -> Unit, onSettings: () -> Unit @@ -59,6 +60,7 @@ fun FeedListScreen( val context = LocalContext.current val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() + val cutoff = remember { System.currentTimeMillis() - RECENT_WINDOW_MS } val feeds by remember { combine(db.feedDao().observeAll(), db.articleDao().observeUnreadCounts()) { feeds, counts -> @@ -67,7 +69,8 @@ fun FeedListScreen( } }.collectAsState(initial = emptyList()) - val totalUnread = feeds.sumOf { it.second } + val recentUnreadCount by remember { db.articleDao().observeUnreadCount(cutoff) }.collectAsState(initial = 0) + val olderUnreadCount by remember { db.articleDao().observeOlderUnreadCount(cutoff) }.collectAsState(initial = 0) var menuExpanded by remember { mutableStateOf(false) } var pendingDelete by remember { mutableStateOf(null) } @@ -124,10 +127,18 @@ fun FeedListScreen( item { ListItem( headlineContent = { Text("Unread") }, - trailingContent = { Text(totalUnread.toString()) }, + trailingContent = { Text(recentUnreadCount.toString()) }, modifier = Modifier.fillMaxWidth().clickable { onOpenUnread() } ) HorizontalDivider() + // The rest of the unread backlog, older than RECENT_WINDOW_MS -- kept + // out of the main Unread view so a large backlog doesn't bury what's new. + ListItem( + headlineContent = { Text("Older") }, + trailingContent = { Text(olderUnreadCount.toString()) }, + modifier = Modifier.fillMaxWidth().clickable { onOpenOlder() } + ) + HorizontalDivider() ListItem( headlineContent = { Text("Saved") }, modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() } diff --git a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt index 43e1680..0d17ab8 100644 --- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt @@ -21,6 +21,10 @@ import java.net.URLEncoder const val UNREAD_FEED_ID = "__unread__" const val SAVED_FEED_ID = "__saved__" +const val OLDER_FEED_ID = "__older__" + +// How far back the default Unread view reaches before an article falls into Older instead. +const val RECENT_WINDOW_MS = 72L * 60 * 60 * 1000 fun encodeNavArg(value: String): String = URLEncoder.encode(value, "UTF-8") fun decodeNavArg(value: String): String = URLDecoder.decode(value, "UTF-8") @@ -55,6 +59,7 @@ fun FeedReaderApp() { FeedListScreen( onOpenFeed = { feedId -> navController.navigate("articles/${encodeNavArg(feedId)}") }, onOpenUnread = { navController.navigate("articles/${encodeNavArg(UNREAD_FEED_ID)}") }, + onOpenOlder = { navController.navigate("articles/${encodeNavArg(OLDER_FEED_ID)}") }, onOpenSaved = { navController.navigate("articles/${encodeNavArg(SAVED_FEED_ID)}") }, onAddFeed = { navController.navigate("addFeed") }, onSettings = { navController.navigate("settings") } 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 eae11af..4becea6 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -84,8 +84,10 @@ fun ReaderScreen( var articleIds by remember(feedId) { mutableStateOf?>(null) } LaunchedEffect(feedId) { + val cutoff = System.currentTimeMillis() - RECENT_WINDOW_MS val flow = when (feedId) { - UNREAD_FEED_ID -> db.articleDao().observeUnread() + UNREAD_FEED_ID -> db.articleDao().observeUnread(cutoff) + OLDER_FEED_ID -> db.articleDao().observeOlderUnread(cutoff) SAVED_FEED_ID -> db.articleDao().observeSaved() else -> db.articleDao().observeByFeed(feedId) } -- cgit v1.2.3