summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-24 03:28:31 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-24 03:28:31 +0000
commit72f76cc41ae406e00a4dd9d448e2b552f9a6baaa (patch)
tree5f23e410b2ac4f8dddf89422ab57e738b3b04b2f /app/src
parentab392e2ab983a4c87e241b457c98244251b2206a (diff)
Collapsible folders, long-press link menu, YouTube thumbnails, strip more Ars sequel chrome
- Feed list: folders now collapse (start collapsed, with a per-folder unread count so collapsing doesn't hide "is there anything new") - Long-pressing a link inside an article shows an action menu (open in browser, share, save to Stash, save for later) instead of WebView's default text-selection popup -- confirmed live end to end, including the Stash/save-for-later paths creating a real standalone article - New shared ensureStandaloneArticle() helper (data/ArticleSaving.kt), factored out of ShareReceiverActivity, now also used by the long-press menu - YouTube iframe embeds become tap-to-open thumbnails (JS is off in the reader WebView, so the real player never worked anyway); tapping one goes straight to the YouTube app/browser instead of our own article-extraction pipeline - Fixed back navigation through a chain of links-within-links: each hop was stacking a new backstack entry, so back stepped through the chain one link at a time instead of returning straight to the source article. Confirmed live: A -> link -> B -> link -> C, one back-press now goes straight to A - Extended the <header> chrome-stripping from last round: also strip <footer>, an author bio card, and a "Most Read" trending-stories widget -- confirmed live one of the latter showed up as a giant unrelated photo plus an empty numbered list (its lazy-loaded items never populate in this hidden, off-screen extraction WebView) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/ArticleSaving.kt30
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt73
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt48
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt49
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt11
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt44
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt86
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt25
8 files changed, 322 insertions, 44 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/ArticleSaving.kt b/app/src/main/java/org/terst/feedreader/data/ArticleSaving.kt
new file mode 100644
index 0000000..f2bcb72
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/ArticleSaving.kt
@@ -0,0 +1,30 @@
+package org.terst.feedreader.data
+
+import android.content.Context
+import org.terst.feedreader.readability.ReaderExtractor
+import java.security.MessageDigest
+
+fun urlToArticleId(url: String): String =
+ MessageDigest.getInstance("SHA-256").digest(url.toByteArray()).joinToString("") { "%02x".format(it) }
+
+/**
+ * Creates (or returns the existing) standalone ArticleEntity for a bare URL not tied to any
+ * feed, extracting it fresh via Readability if it's not already on-device. Shared by every
+ * "save something without opening it in the reader first" entry point: the share-sheet
+ * target, and long-pressing a link inside an article to save/stash it directly.
+ */
+suspend fun ensureStandaloneArticle(context: Context, db: AppDatabase, url: String): ArticleEntity {
+ val id = urlToArticleId(url)
+ db.articleDao().getById(id)?.let { return it }
+ val extracted = ReaderExtractor.extract(context, url)
+ val entity = ArticleEntity(
+ id = id,
+ feedId = null,
+ title = extracted?.title ?: url,
+ link = url,
+ extractedContent = extracted?.content,
+ createdAt = System.currentTimeMillis()
+ )
+ db.articleDao().upsert(entity)
+ return entity
+}
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 f9adbb4..3cb4741 100644
--- a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
+++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
@@ -180,23 +180,70 @@ object ReaderExtractor {
}
}
+ // A YouTube iframe embed is dead weight here -- JS is off in the WebView
+ // that actually displays this content, so the real embedded player could
+ // never initialize anyway (just a blank/broken box). Swap it for a
+ // tap-to-open thumbnail (a real <img>, which Readability treats like any
+ // other content image) linking to the video -- newReaderWebView sends a
+ // YouTube watch link straight to the YouTube app/browser instead of our
+ // own article-extraction pipeline, since there's no article to extract
+ // from a video page.
+ var iframes = document.querySelectorAll(
+ 'iframe[src*="youtube.com/embed/"], iframe[src*="youtube-nocookie.com/embed/"]'
+ );
+ for (var i = 0; i < iframes.length; i++) {
+ var frame = iframes[i];
+ var match = frame.getAttribute('src').match(/embed\/([a-zA-Z0-9_-]+)/);
+ if (!match) continue;
+ var videoId = match[1];
+ var link = document.createElement('a');
+ link.className = 'yt-embed';
+ link.href = 'https://www.youtube.com/watch?v=' + videoId;
+ var thumb = document.createElement('img');
+ thumb.src = 'https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg';
+ thumb.alt = 'YouTube video';
+ var play = document.createElement('span');
+ play.className = 'yt-play';
+ link.appendChild(thumb);
+ link.appendChild(play);
+ frame.parentNode.replaceChild(link, frame);
+ }
+
// A news/blog article page's <header> conventionally holds everything
// BEFORE the real body: a section/category kicker, the dek/subhead, the
- // byline, and the hero image + its caption. Readability doesn't exclude
- // <header> by tag outright (only deprioritizes it in scoring), so on
- // some articles enough of it scores highly enough to get pulled in
+ // byline, and the hero image + its caption. <footer> (site nav/link
+ // menus) is the same story at the other end. Readability doesn't exclude
+ // either by tag outright (only deprioritizes them in scoring), so on
+ // some articles enough of one scores highly enough to get pulled in
// alongside the real body -- confirmed live: a kicker icon, the dek
// paragraph, AND an orphaned duplicate hero-image caption (a mobile-only
- // copy with no adjacent <img> at all) all leaking in above the real
- // lede on an Ars Technica article. This is a pre-processing decision on
- // our side, not a change to Readability's own algorithm: treat <header>
- // as page chrome, never article content, and remove it before Readability
- // ever sees the page. (The article's actual title is shown separately --
- // see ReaderWebView.wrapReaderHtml -- so losing the in-body <h1> here is
- // fine, it was never meant to double as the body text anyway.)
- var headers = document.querySelectorAll('header');
- for (var i = 0; i < headers.length; i++) {
- headers[i].parentNode.removeChild(headers[i]);
+ // copy with no adjacent <img> at all) all leaking in above the real lede
+ // on an Ars Technica article. This is a pre-processing decision on our
+ // side, not a change to Readability's own algorithm: treat <header>/
+ // <footer> as page chrome, never article content, and remove them before
+ // Readability ever sees the page. (The article's actual title is shown
+ // separately -- see ReaderWebView.wrapReaderHtml -- so losing the in-body
+ // <h1> here is fine, it was never meant to double as the body text.)
+ var chrome = document.querySelectorAll('header, footer');
+ for (var i = 0; i < chrome.length; i++) {
+ chrome[i].parentNode.removeChild(chrome[i]);
+ }
+
+ // Two more sibling widgets confirmed live, same "not article content"
+ // story: an author bio card (photo + name + one-paragraph bio, after the
+ // real body -- its photo has the same Tailwind-dependent absolute-fill
+ // styling as the section-badge icon and hero image, so it'd render
+ // oversized for the same underlying reason even if kept) and a "Most
+ // Read"/trending-stories widget (a list of OTHER articles' thumbnails --
+ // confirmed live: one showed up as a giant unrelated photo followed by an
+ // empty numbered list, since the widget's later items only populate their
+ // thumbnail on scroll-into-view, which never happens in this hidden,
+ // off-screen extraction WebView).
+ var sequels = document.querySelectorAll(
+ '[class*="author-mini-bio" i], [class*="author-bio" i], [class*="most-read" i]'
+ );
+ for (var i = 0; i < sequels.length; i++) {
+ if (sequels[i].parentNode) sequels[i].parentNode.removeChild(sequels[i]);
}
// Readability's default hidden-element skip (treating display:none /
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 ee2130c..d87566b 100644
--- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
@@ -13,6 +13,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Article
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
@@ -77,6 +79,11 @@ fun FeedListScreen(
val savedCount by remember { db.articleDao().observeSavedCount() }.collectAsState(initial = 0)
var menuExpanded by remember { mutableStateOf(false) }
var pendingDelete by remember { mutableStateOf<FeedEntity?>(null) }
+ // Folders start collapsed -- that's the whole point of grouping once there are enough
+ // feeds/folders that the flat list was worth organizing in the first place. Session-only
+ // (not persisted): which folders were open isn't something worth remembering across app
+ // restarts, per the app's "simple design" scope.
+ var expandedFolders by remember { mutableStateOf(setOf<String>()) }
val snackbarHostState = remember { SnackbarHostState() }
pendingDelete?.let { feed ->
@@ -186,8 +193,19 @@ fun FeedListScreen(
feedRowItems(failedFeeds)
}
for (folder in folderNames) {
- item { SectionHeader(folder) }
- feedRowItems(groupedByFolder.getValue(folder))
+ val isExpanded = folder in expandedFolders
+ val folderFeeds = groupedByFolder.getValue(folder)
+ item {
+ FolderHeader(
+ title = folder,
+ expanded = isExpanded,
+ unreadCount = folderFeeds.sumOf { it.second },
+ onClick = {
+ expandedFolders = if (isExpanded) expandedFolders - folder else expandedFolders + folder
+ }
+ )
+ }
+ if (isExpanded) feedRowItems(folderFeeds)
}
if (ungrouped.isNotEmpty()) {
if (folderNames.isNotEmpty() || failedFeeds.isNotEmpty()) item { SectionHeader("Other") }
@@ -208,6 +226,32 @@ private fun SectionHeader(title: String) {
}
@Composable
+private fun FolderHeader(title: String, expanded: Boolean, unreadCount: Int, onClick: () -> Unit) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Icon(
+ if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = if (expanded) "Collapse" else "Expand",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ title,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp, vertical = 4.dp)
+ )
+ // A folder collapsed hides its per-feed unread badges too -- show the total here so
+ // collapsing doesn't also hide "is there anything new in here."
+ if (unreadCount > 0) {
+ Text(unreadCount.toString(), color = MaterialTheme.colorScheme.primary)
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ }
+}
+
+@Composable
private fun FeedRow(
feed: FeedEntity,
unreadCount: Int,
diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
index 4c7251f..c001b69 100644
--- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
@@ -46,14 +46,12 @@ import org.terst.feedreader.data.AppDatabase
import org.terst.feedreader.data.ArticleEntity
import org.terst.feedreader.data.Net
import org.terst.feedreader.data.Settings
+import org.terst.feedreader.data.ensureStandaloneArticle
+import org.terst.feedreader.data.urlToArticleId
import org.terst.feedreader.readability.ReaderExtractor
import org.terst.feedreader.stash.StashClient
import org.terst.feedreader.stash.StashSaveResult
import java.net.URLEncoder
-import java.security.MessageDigest
-
-private fun urlToArticleId(url: String): String =
- MessageDigest.getInstance("SHA-256").digest(url.toByteArray()).joinToString("") { "%02x".format(it) }
/**
* A reader view for links tapped inside an article's WebView -- extracts the linked
@@ -83,6 +81,7 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
var isReaderMode by remember(url) { mutableStateOf(true) }
var isSaving by remember(url) { mutableStateOf(false) }
var moreMenuExpanded by remember(url) { mutableStateOf(false) }
+ var longPressedLink by remember(url) { mutableStateOf<String?>(null) }
LaunchedEffect(url) {
val existing = db.articleDao().getById(articleId)
@@ -223,7 +222,7 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
- newReaderWebView(ctx, onOpenLink).apply {
+ newReaderWebView(ctx, onOpenLink, onLongPressLink = { longPressedLink = it }).apply {
loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)
loadedContent.value = currentExtractedContent
}
@@ -255,5 +254,45 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
else -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
+ longPressedLink?.let { linkUrl ->
+ LinkActionsDialog(
+ url = linkUrl,
+ onDismiss = { longPressedLink = null },
+ onOpenInBrowser = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(linkUrl))) },
+ onShare = {
+ val send = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, linkUrl)
+ }
+ context.startActivity(Intent.createChooser(send, null))
+ },
+ onSaveToStash = {
+ scope.launch {
+ val entity = ensureStandaloneArticle(context, db, linkUrl)
+ val settings = Settings(context)
+ val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow())
+ when (val result = client.saveArticle(linkUrl, entity.title)) {
+ is StashSaveResult.Saved -> {
+ db.articleDao().setStashArtifactId(entity.id, result.artifact.id)
+ snackbarHostState.showSnackbar("Saved to Stash")
+ }
+ is StashSaveResult.AlreadySaved -> {
+ db.articleDao().setStashArtifactId(entity.id, result.existingId)
+ snackbarHostState.showSnackbar("Already in Stash")
+ }
+ is StashSaveResult.Failed ->
+ snackbarHostState.showSnackbar("Save failed: ${result.message}")
+ }
+ }
+ },
+ onSaveForLater = {
+ scope.launch {
+ val entity = ensureStandaloneArticle(context, db, linkUrl)
+ if (!entity.isSaved) db.articleDao().setSaved(entity.id, true)
+ snackbarHostState.showSnackbar("Saved for later")
+ }
+ }
+ )
+ }
}
}
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 0d17ab8..f7ca043 100644
--- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
@@ -108,7 +108,16 @@ fun FeedReaderApp() {
LinkReaderScreen(
url = url,
onBack = { navController.popBackStack() },
- onOpenLink = { nextUrl -> navController.navigate("linkReader/${encodeNavArg(nextUrl)}") },
+ // Replaces the current linkReader entry rather than stacking another one on
+ // top -- otherwise a chain of links-within-links (A -> link -> B -> link -> C)
+ // made back step through the chain one hop at a time (C -> B -> A) instead of
+ // returning straight to the source article A, which is what "back" actually
+ // means here: leaving the link-reading detour, not undoing it one link at a time.
+ onOpenLink = { nextUrl ->
+ navController.navigate("linkReader/${encodeNavArg(nextUrl)}") {
+ popUpTo("linkReader/{url}") { inclusive = true }
+ }
+ },
onOpenBrowser = { browserUrl -> navController.navigate("browser/${encodeNavArg(browserUrl)}") }
)
}
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 5ebf2ba..d6111e2 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -64,6 +64,7 @@ import org.terst.feedreader.data.ArticleEntity
import org.terst.feedreader.data.FeedEntity
import org.terst.feedreader.data.Net
import org.terst.feedreader.data.Settings
+import org.terst.feedreader.data.ensureStandaloneArticle
import org.terst.feedreader.readability.ReaderExtractor
import org.terst.feedreader.stash.StashClient
import org.terst.feedreader.stash.StashSaveResult
@@ -182,6 +183,7 @@ private fun ReaderContent(
var moreMenuExpanded by remember(articleId) { mutableStateOf(false) }
var showBugReportDialog by remember(articleId) { mutableStateOf(false) }
var bugReportDescription by remember(articleId) { mutableStateOf("") }
+ var longPressedLink by remember(articleId) { mutableStateOf<String?>(null) }
LaunchedEffect(articleId) {
val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
@@ -454,6 +456,46 @@ private fun ReaderContent(
}
)
}
+ longPressedLink?.let { url ->
+ LinkActionsDialog(
+ url = url,
+ onDismiss = { longPressedLink = null },
+ onOpenInBrowser = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) },
+ onShare = {
+ val send = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, url)
+ }
+ context.startActivity(Intent.createChooser(send, null))
+ },
+ onSaveToStash = {
+ scope.launch {
+ val entity = ensureStandaloneArticle(context, db, url)
+ val settings = Settings(context)
+ val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow())
+ when (val result = client.saveArticle(url, entity.title)) {
+ is StashSaveResult.Saved -> {
+ db.articleDao().setStashArtifactId(entity.id, result.artifact.id)
+ snackbarHostState.showSnackbar("Saved to Stash")
+ }
+ is StashSaveResult.AlreadySaved -> {
+ db.articleDao().setStashArtifactId(entity.id, result.existingId)
+ snackbarHostState.showSnackbar("Already in Stash")
+ }
+ is StashSaveResult.Failed ->
+ snackbarHostState.showSnackbar("Save failed: ${result.message}")
+ }
+ }
+ },
+ onSaveForLater = {
+ scope.launch {
+ val entity = ensureStandaloneArticle(context, db, url)
+ if (!entity.isSaved) db.articleDao().setSaved(entity.id, true)
+ snackbarHostState.showSnackbar("Saved for later")
+ }
+ }
+ )
+ }
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val content = if (isReaderMode) readerContent else (feedContent ?: readerContent)
val baseUrl = article?.link
@@ -468,7 +510,7 @@ private fun ReaderContent(
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
- newReaderWebView(ctx, onOpenLink).apply {
+ newReaderWebView(ctx, onOpenLink, onLongPressLink = { longPressedLink = it }).apply {
loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title), "text/html", "UTF-8", null)
loadedContent.value = content
}
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
index f95dfed..b984d1d 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
@@ -1,10 +1,25 @@
package org.terst.feedreader.ui
import android.content.Context
+import android.content.Intent
+import android.net.Uri
import android.view.View
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+
+private fun isYouTubeWatchUrl(uri: Uri): Boolean {
+ val host = uri.host?.lowercase() ?: return false
+ return host == "youtu.be" || host.endsWith(".youtube.com") || host == "youtube.com"
+}
/**
* Shared factory for the read-only, dark-themed article WebView used by both the
@@ -12,9 +27,17 @@ import android.webkit.WebViewClient
* JS is always off -- extracted/feed HTML never needs it, and this keeps articles
* from running arbitrary scripts. Link taps are intercepted rather than left to
* WebView's default behavior (handing off to the system browser) so following a
- * link stays inside the app's own reader/extraction pipeline.
+ * link stays inside the app's own reader/extraction pipeline -- except a YouTube
+ * watch link (what ReaderExtractor turns an embedded video into a thumbnail link
+ * to), which goes straight to the YouTube app/browser instead: there's nothing for
+ * our own Readability-based reader pipeline to usefully extract from a video page.
+ *
+ * onLongPressLink fires for a long-press on a link (a plain one, or an image
+ * wrapped in one, e.g. the YouTube thumbnail) with the target URL, instead of
+ * WebView's own default long-press behavior (a native "open/copy link" popup) --
+ * callers show their own save/share/open action menu instead.
*/
-fun newReaderWebView(context: Context, onOpenLink: (String) -> Unit): WebView =
+fun newReaderWebView(context: Context, onOpenLink: (String) -> Unit, onLongPressLink: (String) -> Unit = {}): WebView =
WebView(context).apply {
settings.javaScriptEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
@@ -22,12 +45,63 @@ fun newReaderWebView(context: Context, onOpenLink: (String) -> Unit): WebView =
isHorizontalScrollBarEnabled = false
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
- onOpenLink(request.url.toString())
+ val url = request.url
+ if (isYouTubeWatchUrl(url)) {
+ context.startActivity(Intent(Intent.ACTION_VIEW, url))
+ } else {
+ onOpenLink(url.toString())
+ }
return true
}
}
+ setOnLongClickListener {
+ val result = hitTestResult
+ val url = when (result.type) {
+ WebView.HitTestResult.SRC_ANCHOR_TYPE, WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE -> result.extra
+ else -> null
+ }
+ if (url != null) {
+ onLongPressLink(url)
+ true
+ } else {
+ false
+ }
+ }
}
+@Composable
+fun LinkActionsDialog(
+ url: String,
+ onDismiss: () -> Unit,
+ onOpenInBrowser: () -> Unit,
+ onShare: () -> Unit,
+ onSaveToStash: () -> Unit,
+ onSaveForLater: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(url, maxLines = 2, overflow = TextOverflow.Ellipsis) },
+ text = {
+ Column {
+ TextButton(onClick = { onDismiss(); onOpenInBrowser() }, modifier = Modifier.fillMaxWidth()) {
+ Text("Open in browser")
+ }
+ TextButton(onClick = { onDismiss(); onShare() }, modifier = Modifier.fillMaxWidth()) {
+ Text("Share")
+ }
+ TextButton(onClick = { onDismiss(); onSaveToStash() }, modifier = Modifier.fillMaxWidth()) {
+ Text("Save to Stash")
+ }
+ TextButton(onClick = { onDismiss(); onSaveForLater() }, modifier = Modifier.fillMaxWidth()) {
+ Text("Save for later")
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
+ )
+}
+
private fun escapeHtml(text: String): String = text
.replace("&", "&amp;")
.replace("<", "&lt;")
@@ -61,6 +135,12 @@ fun wrapReaderHtml(bodyContent: String, title: String? = null): String = """
explicit width/height was deliberately sized by the page itself, so leave
those alone -- just cap the ones that would otherwise size themselves. */
svg:not([width]):not([height]) { max-width: 32px; max-height: 32px; }
+ /* A tap-to-open thumbnail standing in for a YouTube embed (see ReaderExtractor) --
+ JS is off here, so the real embedded player could never have worked anyway. */
+ .yt-embed { position: relative; display: block; margin: 0.5em 0; }
+ .yt-embed .yt-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
+ width: 0; height: 0; border-style: solid; border-width: 18px 0 18px 30px;
+ border-color: transparent transparent transparent rgba(255,255,255,0.85); }
</style></head>
<body>${if (!title.isNullOrBlank()) "<h1 class=\"article-title\">${escapeHtml(title)}</h1>" else ""}$bodyContent</body></html>
""".trimIndent()
diff --git a/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt
index 656bebc..1a383ae 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt
@@ -22,13 +22,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import org.terst.feedreader.data.AppDatabase
-import org.terst.feedreader.data.ArticleEntity
import org.terst.feedreader.data.Net
import org.terst.feedreader.data.Settings
-import org.terst.feedreader.readability.ReaderExtractor
+import org.terst.feedreader.data.ensureStandaloneArticle
import org.terst.feedreader.stash.StashClient
import org.terst.feedreader.stash.StashSaveResult
-import java.security.MessageDigest
private val URL_REGEX = Regex("""https?://\S+""")
@@ -63,29 +61,18 @@ private fun ShareReceiverContent(url: String?, onDone: () -> Unit) {
if (url == null) return@LaunchedEffect
val db = AppDatabase.get(context)
- val extracted = ReaderExtractor.extract(context, url)
- val id = MessageDigest.getInstance("SHA-256").digest(url.toByteArray()).joinToString("") { "%02x".format(it) }
- db.articleDao().upsert(
- ArticleEntity(
- id = id,
- feedId = null,
- title = extracted?.title ?: url,
- link = url,
- extractedContent = extracted?.content,
- isSaved = true,
- createdAt = System.currentTimeMillis()
- )
- )
+ val entity = ensureStandaloneArticle(context, db, url)
+ if (!entity.isSaved) db.articleDao().setSaved(entity.id, true)
val settings = Settings(context)
val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow())
- status = when (val result = client.saveArticle(url, extracted?.title)) {
+ status = when (val result = client.saveArticle(url, entity.title)) {
is StashSaveResult.Saved -> {
- db.articleDao().setStashArtifactId(id, result.artifact.id)
+ db.articleDao().setStashArtifactId(entity.id, result.artifact.id)
"Saved for offline reading and archived to Stash"
}
is StashSaveResult.AlreadySaved -> {
- db.articleDao().setStashArtifactId(id, result.existingId)
+ db.articleDao().setStashArtifactId(entity.id, result.existingId)
"Saved for offline reading (already in Stash)"
}
is StashSaveResult.Failed ->