From 91edd1f5f29a3336fa9e3bd1762c70ea74e3e48d Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 22 Jul 2026 18:26:14 +0000 Subject: Split save/Stash actions, follow links in-app via reader pipeline, tune scroll, fix title entities - ReaderScreen: bookmark icon is now a purely local save (just flips isSaved, no network) -- Stash archiving is a separate explicit action (new cloud icon), since it's a heavier networked action the user should trigger on purpose rather than have bundled into "save." Verified via direct DB queries and (once I stopped misreading small screenshots) the icon updates immediately in-session for both actions. - New ReaderWebView.kt: shared WebView factory for the dark reader palette and link handling, used by both ReaderScreen and the new LinkReaderScreen. Links tapped inside an article's WebView now open in a lightweight in-app reader (extracted via the same Readability pipeline, no DB persistence) instead of falling through to the system browser -- recursive, so a chain of links stays in-app. Verified live: tapping a link mid-article navigated to a real extracted page with a working back stack. - WebView scroll tuning: overscroll glow and scrollbars off, on both the article reader and the new link reader -- aimed at the reported "still a little choppy" scrolling on top of the previous reload-on-recompose fix. - FeedParser: some feeds (kottke.org among them) double-encode titles -- the XML parser resolves outer XML escaping, but leftover HTML entities like "“" were showing up literally in the title text. Titles are now run through a second HTML-entity decode pass. Verified: a kottke title that previously showed a literal “ now renders the actual curly quote. All changes verified live on the emulator against the real 107-feed dataset. --- .../java/org/terst/feedreader/ui/ReaderScreen.kt | 51 +++++++++++----------- 1 file changed, 25 insertions(+), 26 deletions(-) (limited to 'app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt') 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 2177d72..ced9482 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -2,7 +2,6 @@ package org.terst.feedreader.ui import android.content.Intent import android.net.Uri -import android.webkit.WebView import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,6 +14,8 @@ import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.outlined.Article import androidx.compose.material3.CircularProgressIndicator @@ -63,7 +64,7 @@ private const val THIN_CONTENT_THRESHOLD = 500 */ @OptIn(ExperimentalFoundationApi::class) @Composable -fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit) { +fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } var articleIds by remember(feedId) { mutableStateOf?>(null) } @@ -91,13 +92,13 @@ fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit) { val pagerState = rememberPagerState(initialPage = initialIndex) { safeIds.size } HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> - ReaderContent(articleId = safeIds[page], onBack = onBack) + ReaderContent(articleId = safeIds[page], onBack = onBack, onOpenLink = onOpenLink) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ReaderContent(articleId: String, onBack: () -> Unit) { +private fun ReaderContent(articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() @@ -204,6 +205,20 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { }) { Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser") } + // Purely local: just flips isSaved (shows up in the Saved list). Does NOT + // touch Stash -- that's a separate, explicit action below, since archiving + // to Stash is a heavier/networked action the user should trigger on purpose. + IconButton(onClick = { + val a = currentArticle ?: return@IconButton + val newSaved = !a.isSaved + article = a.copy(isSaved = newSaved) + scope.launch { db.articleDao().setSaved(a.id, newSaved) } + }) { + Icon( + if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = "Save locally" + ) + } IconButton( enabled = !isSaving, onClick = { @@ -214,13 +229,13 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow()) when (val result = client.saveArticle(a.link, a.title)) { is StashSaveResult.Saved -> { - db.articleDao().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.artifact.id) + article = a.copy(stashArtifactId = result.artifact.id) snackbarHostState.showSnackbar("Saved to Stash") } is StashSaveResult.AlreadySaved -> { - db.articleDao().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.existingId) + article = a.copy(stashArtifactId = result.existingId) snackbarHostState.showSnackbar("Already in Stash") } is StashSaveResult.Failed -> @@ -231,7 +246,7 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { } ) { Icon( - if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + if (currentArticle?.stashArtifactId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload, contentDescription = "Save to Stash" ) } @@ -254,15 +269,14 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> - WebView(ctx).apply { - settings.javaScriptEnabled = false - loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) + newReaderWebView(ctx, onOpenLink).apply { + loadDataWithBaseURL(baseUrl, wrapReaderHtml(content), "text/html", "UTF-8", null) loadedContent.value = content } }, update = { webView -> if (loadedContent.value != content) { - webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) + webView.loadDataWithBaseURL(baseUrl, wrapReaderHtml(content), "text/html", "UTF-8", null) loadedContent.value = content } }, @@ -278,18 +292,3 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { } } } - -// Hardcoded dark palette, not @media (prefers-color-scheme) -- the app itself always -// forces dark colorScheme() regardless of system setting (see MainActivity), so the -// reader content should match rather than following a system light/dark that the rest -// of the app ignores. -private fun wrapHtml(bodyContent: String): String = """ - - - $bodyContent -""".trimIndent() -- cgit v1.2.3