diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-24 03:28:31 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-24 03:28:31 +0000 |
| commit | 72f76cc41ae406e00a4dd9d448e2b552f9a6baaa (patch) | |
| tree | 5f23e410b2ac4f8dddf89422ab57e738b3b04b2f /app/src/main/java/org/terst | |
| parent | ab392e2ab983a4c87e241b457c98244251b2206a (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/main/java/org/terst')
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("&", "&") .replace("<", "<") @@ -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 -> |
