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 import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons 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.OpenInBrowser import androidx.compose.material.icons.outlined.Article import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase 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.readability.ReaderExtractor import org.terst.feedreader.stash.StashClient import org.terst.feedreader.stash.StashSaveResult // Below this length (of the plain feed-provided HTML), assume it's a teaser/summary, // not the real article body, and auto-fall-back to on-device Readability extraction. private const val THIN_CONTENT_THRESHOLD = 500 /** * Swipeable wrapper: snapshots the article-id order of the list this reader * was opened from (Unread/Saved/a specific feed) ONCE at entry, then pages * across that fixed queue. Deliberately a snapshot, not a live Flow -- opening * an article marks it read, which would otherwise drop it out of the live * Unread query mid-swipe and shift every subsequent page underneath the user. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } var articleIds by remember(feedId) { mutableStateOf?>(null) } LaunchedEffect(feedId) { val flow = when (feedId) { UNREAD_FEED_ID -> db.articleDao().observeUnread() SAVED_FEED_ID -> db.articleDao().observeSaved() else -> db.articleDao().observeByFeed(feedId) } articleIds = flow.first().map { it.id } } val ids = articleIds if (ids == null) { Box(modifier = Modifier.fillMaxSize()) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } return } // The article may have been opened from somewhere not in this queue (shouldn't normally // happen, but fall back to a single-item queue rather than crashing on indexOfFirst = -1). val safeIds = if (ids.contains(initialArticleId)) ids else listOf(initialArticleId) val initialIndex = safeIds.indexOf(initialArticleId).coerceAtLeast(0) val pagerState = rememberPagerState(initialPage = initialIndex) { safeIds.size } HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> ReaderContent(articleId = safeIds[page], onBack = onBack) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent(articleId: String, onBack: () -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } var article by remember(articleId) { mutableStateOf(null) } var feed by remember(articleId) { mutableStateOf(null) } var feedContent by remember(articleId) { mutableStateOf(null) } var readerContent by remember(articleId) { mutableStateOf(null) } var isReaderMode by remember(articleId) { mutableStateOf(false) } var isExtracting by remember(articleId) { mutableStateOf(false) } var isSaving by remember(articleId) { mutableStateOf(false) } LaunchedEffect(articleId) { val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect article = loaded db.articleDao().setRead(articleId, true) val loadedFeed = loaded.feedId?.let { db.feedDao().getById(it) } feed = loadedFeed feedContent = loaded.feedContentHtml readerContent = loaded.extractedContent val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0 if (loadedFeed?.readerModeDisabled != true && readerContent == null && plainLength < THIN_CONTENT_THRESHOLD) { // Feed content is too thin to be the real article -- auto-extract, since in this // case reader mode isn't really an alternative view, it's the only real content. isExtracting = true val extracted = ReaderExtractor.extract(context, loaded.link) isExtracting = false extracted?.content?.let { readerContent = it db.articleDao().setExtractedContent(articleId, it) } isReaderMode = true } } fun toggleReaderMode() { val a = article ?: return if (feed?.readerModeDisabled == true) return if (isReaderMode) { isReaderMode = false return } if (readerContent != null) { isReaderMode = true return } scope.launch { isExtracting = true val extracted = ReaderExtractor.extract(context, a.link) isExtracting = false val content = extracted?.content if (content != null) { readerContent = content db.articleDao().setExtractedContent(articleId, content) isReaderMode = true } else { snackbarHostState.showSnackbar("Couldn't extract a reader view for this article") } } } Scaffold( topBar = { TopAppBar( title = { Column { Text(article?.title.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) feed?.title?.let { Text( it, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } }, actions = { val currentArticle = article // Only offer the toggle when there's an actual feed-provided alternative to // switch back to (if we already had to auto-extract for thin feed content, // reader mode IS the only content) and the feed hasn't opted out of it. if (feedContent != null && feed?.readerModeDisabled != true) { IconButton(enabled = !isExtracting, onClick = { toggleReaderMode() }) { Icon( if (isReaderMode) Icons.Filled.Article else Icons.Outlined.Article, contentDescription = "Toggle reader view" ) } } IconButton(onClick = { val a = currentArticle ?: return@IconButton val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link)) context.startActivity(browserIntent) }) { Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser") } IconButton( enabled = !isSaving, onClick = { val a = currentArticle ?: return@IconButton isSaving = true scope.launch { val settings = Settings(context) 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) snackbarHostState.showSnackbar("Saved to Stash") } is StashSaveResult.AlreadySaved -> { db.articleDao().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.existingId) snackbarHostState.showSnackbar("Already in Stash") } is StashSaveResult.Failed -> snackbarHostState.showSnackbar("Save failed: ${result.message}") } isSaving = false } } ) { Icon( if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, contentDescription = "Save to Stash" ) } } ) }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { val content = if (isReaderMode) readerContent else (feedContent ?: readerContent) val baseUrl = article?.link if (content != null) { // AndroidView's update lambda reruns on every recomposition of this // composable -- including ones unrelated to content, like isSaving // flipping while a Stash save is in flight. Reloading the WebView's HTML // on those unrelated recompositions was resetting scroll position mid-swipe, // which is what made scrolling feel unsmooth/janky. Track what's actually // loaded and only reload when the content string itself changes. val loadedContent = remember(articleId) { mutableStateOf(null) } AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> WebView(ctx).apply { settings.javaScriptEnabled = false loadDataWithBaseURL(baseUrl, wrapHtml(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) loadedContent.value = content } }, // AndroidView does not destroy() the underlying WebView on its own -- // without this, swiping through many articles (each page holding its own // WebView) leaks a WebView's native resources per page and eventually // exhausts memory over a long reading session. onRelease = { webView -> webView.destroy() } ) } else if (isExtracting) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } } // 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()