diff --git a/README.md b/README.md index 4b1137fd4..b24732a4a 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,17 @@ user interface and experience to browse through the feeds, and supports Material ## Features ✨ - Supports RSS & Atom feeds -- Gorgeous home feed -- Pin frequently visited feeds +- Feed management: Add, Edit & Pin feeds +- Feed grouping +- Access to pinned feeds/groups from bottom bar - Smart fetching: Twine looks for feeds when given any website homepage -- Reading view with shortcut to fetch full article +- Article shortcut to fetch full article in reader view - Bookmark posts to read later - Search posts - Background sync -- Feed management: Add, Edit & Pin feeds -- Import and exports your feeds with OPML +- Import and exports your feeds with OPML +- Dynamic content theming +- Light/Dark mode support ## Tech Stack 📚 diff --git a/shared/src/commonMain/composeResources/files/reader/main.js b/shared/src/commonMain/composeResources/files/reader/main.js index 96849e02c..e404bf7d1 100644 --- a/shared/src/commonMain/composeResources/files/reader/main.js +++ b/shared/src/commonMain/composeResources/files/reader/main.js @@ -67,19 +67,25 @@ function updateStyles(colors) { font-family: 'Golos Text', sans-serif; overflow-wrap: break-word; } + body:dir(rtl) { + padding-inline-start: 16px; + } + body:dir(ltr) { + padding-inline-end: 16px; + } a { color: ${colors.linkColor}; } ul li::before { content: "\u2022"; color: ${colors.textColor}; - margin-right: 0.25em; + margin-inline-end: 0.25em; } ol li::before { counter-increment: item; content: counters(item, ".") "."; color: ${colors.textColor}; - margin-right: 0.25em; + margin-inline-end: 0.25em; } code, pre { font-family: 'Source Code Pro', monospace; @@ -93,7 +99,7 @@ function updateStyles(colors) { border: 1px solid ${colors.dividerColor}; } blockquote { - margin-left: 8px; + margin-inline-start: 8px; padding-left: 8px; border-left: 4px solid ${colors.linkColor} } @@ -105,11 +111,22 @@ function updateStyles(colors) { } async function renderReaderView(link, html, colors) { - console.log('Parsing content'); + console.log('Preparing reader content for rendering'); + //noinspection JSUnresolvedVariable + window.kmpJsBridge.callNative( + "renderProgress", + "Loading", + {} + ); + //noinspection JSUnresolvedVariable const result = await parse(html, link); const content = result.content || html; + document.getElementById("content").innerHTML += content; + document.querySelectorAll("pre").forEach((element) => + element.dir = "auto" + ); updateStyles(colors) processLinks(); @@ -117,4 +134,12 @@ async function renderReaderView(link, html, colors) { removeTitle(); document.body.style.display = "block"; + + console.log('Reader content rendered') + //noinspection JSUnresolvedVariable + window.kmpJsBridge.callNative( + "renderProgress", + "Idle", + {} + ); } diff --git a/shared/src/commonMain/composeResources/files/reader/styles.css b/shared/src/commonMain/composeResources/files/reader/styles.css index c48531a32..ece95a4c6 100644 --- a/shared/src/commonMain/composeResources/files/reader/styles.css +++ b/shared/src/commonMain/composeResources/files/reader/styles.css @@ -18,7 +18,7 @@ figcaption { align-items: center; } .column { - margin-left: 16px; + margin-inline-start: 16px; flex: 1; } .caption { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/Switch.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/Switch.kt index dc9b71571..7fa9c201e 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/Switch.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/Switch.kt @@ -17,7 +17,6 @@ package dev.sasikanth.rss.reader.components import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.sasikanth.rss.reader.ui.AppTheme @@ -26,7 +25,7 @@ import dev.sasikanth.rss.reader.ui.AppTheme fun Switch(checked: Boolean, modifier: Modifier = Modifier, onCheckedChange: (Boolean) -> Unit) { MaterialTheme( colorScheme = - darkColorScheme( + MaterialTheme.colorScheme.copy( primary = AppTheme.colorScheme.tintedForeground, onPrimary = AppTheme.colorScheme.tintedSurface, outline = AppTheme.colorScheme.outline, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt index 99522812e..c9b31e424 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt @@ -53,7 +53,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -314,7 +313,9 @@ private fun SearchBar( } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - MaterialTheme(colorScheme = darkColorScheme(primary = AppTheme.colorScheme.tintedForeground)) { + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy(primary = AppTheme.colorScheme.tintedForeground) + ) { OutlinedTextField( modifier = Modifier.weight(1f) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/group/ui/GroupScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/group/ui/GroupScreen.kt index 28333c37a..24cc9061e 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/group/ui/GroupScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/group/ui/GroupScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -195,7 +194,9 @@ fun GroupNameTextField( onValueChanged(input) } - MaterialTheme(colorScheme = darkColorScheme(primary = AppTheme.colorScheme.tintedForeground)) { + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy(primary = AppTheme.colorScheme.tintedForeground) + ) { OutlinedTextField( modifier = modifier, value = input.copy(selection = TextRange(input.text.length)), diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt index a931609b8..0491f5b30 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt @@ -36,17 +36,24 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.multiplatform.webview.jsbridge.IJsMessageHandler +import com.multiplatform.webview.jsbridge.JsMessage import com.multiplatform.webview.jsbridge.rememberWebViewJsBridge import com.multiplatform.webview.web.LoadingState import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.WebViewNavigator import com.multiplatform.webview.web.rememberWebViewNavigator import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData import dev.sasikanth.rss.reader.platform.LocalLinkHandler @@ -57,6 +64,7 @@ import dev.sasikanth.rss.reader.reader.ReaderState.PostMode.Idle import dev.sasikanth.rss.reader.reader.ReaderState.PostMode.InProgress import dev.sasikanth.rss.reader.reader.ReaderState.PostMode.RssContent import dev.sasikanth.rss.reader.reader.ReaderState.PostMode.Source +import dev.sasikanth.rss.reader.reader.ui.ReaderRenderProgressHandler.ReaderRenderLoadingState import dev.sasikanth.rss.reader.resources.icons.ArrowBack import dev.sasikanth.rss.reader.resources.icons.ArticleShortcut import dev.sasikanth.rss.reader.resources.icons.Bookmark @@ -149,10 +157,11 @@ internal fun ReaderScreen( Color.Transparent } - when (state.postMode) { - Idle, - RssContent, - Source -> { + when { + state.content == null || + state.postMode == Idle || + state.postMode == RssContent || + state.postMode == Source -> { IconButton( colors = IconButtonDefaults.iconButtonColors(containerColor = iconBackground), onClick = { @@ -166,7 +175,7 @@ internal fun ReaderScreen( ) } } - InProgress -> { + (state.postMode == InProgress && state.content != null) -> { CircularProgressIndicator( color = AppTheme.colorScheme.tintedForeground, modifier = Modifier.requiredSize(24.dp) @@ -196,88 +205,123 @@ internal fun ReaderScreen( containerColor = AppTheme.colorScheme.surfaceContainerLowest, contentColor = Color.Unspecified ) { paddingValues -> - when { - state.content == null -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator( - color = AppTheme.colorScheme.tintedForeground, - strokeWidth = 4.dp + var renderingState by remember { mutableStateOf(ReaderRenderLoadingState.Loading) } + + if (state.canShowReaderView) { + val navigator = rememberWebViewNavigator() + val jsBridge = rememberWebViewJsBridge() + + DisposableEffect(jsBridge) { + jsBridge.register( + ReaderLinkHandler( + openLink = { link -> coroutineScope.launch { linkHandler.openLink(link) } } ) - } - } - state.canShowReaderView -> { - val navigator = rememberWebViewNavigator() - val jsBridge = rememberWebViewJsBridge() + ) - LaunchedEffect(jsBridge) { - jsBridge.register( - ReaderLinkHandler( - openLink = { link -> coroutineScope.launch { linkHandler.openLink(link) } } - ) + jsBridge.register( + ReaderRenderProgressHandler( + renderState = { newRenderingState -> renderingState = newRenderingState } ) - } + ) - val codeBackgroundColor = AppTheme.colorScheme.surfaceContainerHighest.hexString() - val textColor = AppTheme.colorScheme.onSurface.hexString() - val linkColor = AppTheme.colorScheme.tintedForeground.hexString() - val dividerColor = AppTheme.colorScheme.surfaceContainerHigh.hexString() + onDispose { jsBridge.clear() } + } - val colors = - ReaderHTMLColors( - textColor = textColor, - linkColor = linkColor, - dividerColor = dividerColor, - codeBackgroundColor = codeBackgroundColor - ) + val codeBackgroundColor = AppTheme.colorScheme.surfaceContainerHighest.hexString() + val textColor = AppTheme.colorScheme.onSurface.hexString() + val linkColor = AppTheme.colorScheme.tintedForeground.hexString() + val dividerColor = AppTheme.colorScheme.surfaceContainerHigh.hexString() - val webViewState = rememberWebViewStateWithHTMLData("") - webViewState.webSettings.apply { - this.backgroundColor = AppTheme.colorScheme.surfaceContainerLowest - this.supportZoom = false - } + val colors = + ReaderHTMLColors( + textColor = textColor, + linkColor = linkColor, + dividerColor = dividerColor, + codeBackgroundColor = codeBackgroundColor + ) - LaunchedEffect(state.content) { - withContext(dispatchersProvider.io) { - val htmlTemplate = - ReaderHTML.create( - title = state.title!!, - feedName = state.feed!!.name, - feedHomePageLink = state.feed!!.homepageLink, - feedIcon = state.feed!!.icon, - publishedAt = state.publishedAt!! - ) + val webViewState = rememberWebViewStateWithHTMLData("") + webViewState.webSettings.apply { + this.backgroundColor = AppTheme.colorScheme.surfaceContainerLowest + this.supportZoom = false + } - navigator.loadHtml(htmlTemplate, state.link) - } + LaunchedEffect(state.content) { + withContext(dispatchersProvider.io) { + val htmlTemplate = + ReaderHTML.create( + title = state.title!!, + feedName = state.feed!!.name, + feedHomePageLink = state.feed!!.homepageLink, + feedIcon = state.feed!!.icon, + publishedAt = state.publishedAt!! + ) + + navigator.loadHtml(htmlTemplate, state.link) } + } - LaunchedEffect(webViewState.loadingState) { - withContext(dispatchersProvider.io) { - val hasHtmlTemplateLoaded = - webViewState.loadingState == LoadingState.Finished && !state.content.isNullOrBlank() + LaunchedEffect(webViewState.loadingState) { + withContext(dispatchersProvider.io) { + val hasHtmlTemplateLoaded = + webViewState.loadingState == LoadingState.Finished && !state.content.isNullOrBlank() - if (hasHtmlTemplateLoaded) { - navigator.evaluateJavaScript( - script = - "renderReaderView(${state.link.asJSString}, ${state.content.asJSString}, ${colors.asJSString})" - ) - } + if (hasHtmlTemplateLoaded) { + navigator.evaluateJavaScript( + script = + "renderReaderView(${state.link.asJSString}, ${state.content.asJSString}, ${colors.asJSString})" + ) } } + } - Box(Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = 16.dp)) { - WebView( - modifier = Modifier.fillMaxSize(), - state = webViewState, - navigator = navigator, - webViewJsBridge = jsBridge, - captureBackPresses = false, + Box(Modifier.fillMaxSize().padding(paddingValues).padding(start = 16.dp)) { + WebView( + modifier = Modifier.fillMaxSize(), + state = webViewState, + navigator = navigator, + webViewJsBridge = jsBridge, + captureBackPresses = false, + ) + } + } + + when { + renderingState == ReaderRenderLoadingState.Loading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = AppTheme.colorScheme.tintedForeground, + strokeWidth = 4.dp ) } } - else -> { + !state.canShowReaderView && state.content.isNullOrBlank() -> { Text("No reader content") } } } } + +private class ReaderRenderProgressHandler( + private val renderState: (ReaderRenderLoadingState) -> Unit, +) : IJsMessageHandler { + + enum class ReaderRenderLoadingState { + Loading, + Idle + } + + override fun handle( + message: JsMessage, + navigator: WebViewNavigator?, + callback: (String) -> Unit + ) { + if (message.params.isNotBlank()) { + renderState(ReaderRenderLoadingState.valueOf(message.params)) + } + } + + override fun methodName(): String { + return "renderProgress" + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/search/ui/SearchScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/search/ui/SearchScreen.kt index b803fd09e..186c9b439 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/search/ui/SearchScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/search/ui/SearchScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -220,7 +219,8 @@ private fun SearchBar( .padding(horizontal = 4.dp) ) { MaterialTheme( - colorScheme = darkColorScheme(primary = AppTheme.colorScheme.tintedForeground) + colorScheme = + MaterialTheme.colorScheme.copy(primary = AppTheme.colorScheme.tintedForeground) ) { TextField( modifier =