summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt37
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt39
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt52
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt15
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt5
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt4
6 files changed, 124 insertions, 28 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt
index d089885..22f1d22 100644
--- a/app/src/main/java/org/terst/feedreader/data/Daos.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt
@@ -61,10 +61,34 @@ private const val ARTICLE_LIST_FROM = "articles LEFT JOIN feeds ON articles.feed
@Dao
interface ArticleDao {
- // Unread-first, newest-first -- the default "what's new" view across every feed.
- @Query("SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM WHERE articles.isRead = 0 ORDER BY articles.publishedAt DESC")
- fun observeUnread(): Flow<List<ArticleListItem>>
-
+ // The default "what's new" view -- clamped to the last RECENT_WINDOW_MS (see
+ // MainActivity) so a backlog of hundreds of older unread items doesn't bury
+ // what's actually new. Undated articles (publishedAt failed to parse) are kept
+ // here rather than silently dropped from both views.
+ @Query(
+ "SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM " +
+ "WHERE articles.isRead = 0 AND (articles.publishedAt IS NULL OR articles.publishedAt >= :cutoff) " +
+ "ORDER BY articles.publishedAt DESC"
+ )
+ fun observeUnread(cutoff: Long): Flow<List<ArticleListItem>>
+
+ // The rest of the unread backlog, older than the cutoff -- its own view rather
+ // than mixed into the main one.
+ @Query(
+ "SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM " +
+ "WHERE articles.isRead = 0 AND articles.publishedAt IS NOT NULL AND articles.publishedAt < :cutoff " +
+ "ORDER BY articles.publishedAt DESC"
+ )
+ fun observeOlderUnread(cutoff: Long): Flow<List<ArticleListItem>>
+
+ @Query("SELECT COUNT(*) FROM articles WHERE isRead = 0 AND (publishedAt IS NULL OR publishedAt >= :cutoff)")
+ fun observeUnreadCount(cutoff: Long): Flow<Int>
+
+ @Query("SELECT COUNT(*) FROM articles WHERE isRead = 0 AND publishedAt IS NOT NULL AND publishedAt < :cutoff")
+ fun observeOlderUnreadCount(cutoff: Long): Flow<Int>
+
+ // Per-feed unread badge counts in the feed list -- deliberately NOT time-clamped,
+ // so a feed's number reflects its whole backlog regardless of the Unread/Older split.
@Query("SELECT feedId, COUNT(*) as count FROM articles WHERE isRead = 0 AND feedId IS NOT NULL GROUP BY feedId")
fun observeUnreadCounts(): Flow<List<FeedUnreadCount>>
@@ -89,6 +113,11 @@ interface ArticleDao {
@Query("UPDATE articles SET isRead = :read WHERE id = :id")
suspend fun setRead(id: String, read: Boolean)
+ // Explicit bulk action ("mark read from here") -- batched per confirmed tap,
+ // never called implicitly (e.g. by scrolling).
+ @Query("UPDATE articles SET isRead = 1 WHERE id IN (:ids)")
+ suspend fun markReadBatch(ids: List<String>)
+
@Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId")
suspend fun markAllReadForFeed(feedId: String)
diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
index 28c762c..7e8e10c 100644
--- a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
+++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
@@ -69,16 +69,29 @@ object ReaderExtractor {
val script = """
(function() {
try {
- // Some "soft" paywalls deliver the full article in the initial HTML
- // response to every visitor, then hide it with CSS (inline or via a
- // stylesheet rule) pending a client-side subscription check -- the
- // content itself isn't withheld by the server, only its visibility is.
- // Readability has a built-in heuristic that skips anything hidden this
- // way (treating it as boilerplate to discard), which is exactly what
- // was throwing away real, already-delivered article text and leaving
- // only the visible teaser/subscribe prompt. Un-hide before extracting --
- // this must run on the live document (getComputedStyle needs a node
- // attached to the render tree), so it happens before the clone below.
+ $readabilityJs
+ function plainLength(article) {
+ return (article && article.textContent) ? article.textContent.trim().length : 0;
+ }
+
+ // Readability's default hidden-element skip (treating display:none /
+ // visibility:hidden nodes as boilerplate) is correct for the vast
+ // majority of sites -- it's what keeps collapsed nav/footer/sidebar
+ // chrome out of the result. Try it first, unmodified.
+ var normalArticle = new Readability(document.cloneNode(true)).parse();
+ if (plainLength(normalArticle) >= 500) {
+ return JSON.stringify(normalArticle);
+ }
+
+ // Only reached when the normal pass came back thin. Some "soft"
+ // paywalls deliver the full article in the initial HTML response to
+ // every visitor, then hide it with CSS (inline or via a stylesheet
+ // rule) pending a client-side subscription check -- the content isn't
+ // withheld by the server, only its visibility is, and Readability's
+ // hidden-element skip discards it the same as any other hidden chrome.
+ // Un-hide and retry; keep whichever pass actually found more text,
+ // since un-hiding everything indiscriminately can just as easily pull
+ // in an unrelated hidden footer/notice on a normal (non-paywalled) page.
var all = document.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {
var el = all[i];
@@ -88,9 +101,9 @@ object ReaderExtractor {
el.style.setProperty('visibility', 'visible', 'important');
}
}
- $readabilityJs
- var article = new Readability(document.cloneNode(true)).parse();
- return JSON.stringify(article);
+ var unhiddenArticle = new Readability(document.cloneNode(true)).parse();
+ var best = plainLength(unhiddenArticle) > plainLength(normalArticle) ? unhiddenArticle : normalArticle;
+ return JSON.stringify(best);
} catch (e) {
return JSON.stringify({error: e.toString()});
}
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<String?>(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<ArticleListItem?>(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<FeedEntity?>(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<List<String>?>(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)
}