Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
Expand All @@ -24,10 +25,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.ImageLoader
Expand All @@ -37,13 +40,15 @@ import com.prof18.feedflow.core.model.AudioEnclosure
import com.prof18.feedflow.core.model.FeedItemUrlInfo
import com.prof18.feedflow.core.model.ReaderModeData
import com.prof18.feedflow.core.model.ReaderModeState
import com.prof18.feedflow.core.utils.ContentDirection
import com.prof18.feedflow.desktop.ui.LocalFeedFlowStrings
import com.prof18.feedflow.desktop.ui.LocalReduceMotion
import com.prof18.feedflow.desktop.ui.components.DesktopDesignTokens
import com.prof18.feedflow.desktop.ui.components.FeedFlowVerticalScrollbar
import com.prof18.feedflow.desktop.ui.components.TopToolbarContentFade
import com.prof18.feedflow.shared.domain.buildReaderModeMarkdown
import com.prof18.feedflow.shared.domain.readerLineHeightToTextLineHeightSp
import com.prof18.feedflow.shared.domain.readerModeContentDirection
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.jewel.foundation.theme.JewelTheme
Expand Down Expand Up @@ -92,6 +97,11 @@ internal fun DesktopReaderContent(

ReaderModeState.Loading -> ReaderArticle(
articleKey = loadingArticle?.id,
direction = readerModeContentDirection(
title = loadingArticle?.title,
siteName = loadingArticle?.feedSourceTitle,
markdown = null,
),
title = loadingArticle?.title,
siteName = loadingArticle?.feedSourceTitle,
fontSize = fontSize,
Expand All @@ -100,6 +110,13 @@ internal fun DesktopReaderContent(

is ReaderModeState.Success -> ReaderArticle(
articleKey = s.readerModeData.id.id,
direction = remember(s.readerModeData) {
readerModeContentDirection(
title = s.readerModeData.title,
siteName = s.readerModeData.siteName,
markdown = s.readerModeData.content,
)
},
title = s.readerModeData.title,
siteName = s.readerModeData.siteName,
fontSize = fontSize,
Expand Down Expand Up @@ -137,6 +154,7 @@ internal fun DesktopReaderContent(
@Composable
private fun ReaderArticle(
articleKey: String?,
direction: ContentDirection?,
title: String?,
siteName: String?,
fontSize: Int,
Expand All @@ -154,12 +172,25 @@ private fun ReaderArticle(
.testTag(READER_DOCUMENT_TAG),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier.widthIn(max = readerModeMaxContentWidth).fillMaxWidth()
.padding(horizontal = 32.dp).padding(top = 24.dp, bottom = 88.dp),
) {
ReaderArticleHeader(title = title, siteName = siteName, fontSize = fontSize, lineHeight = lineHeight)
if (body != null) body() else DelayedReaderSpinner(articleKey)
// The article is laid out from its own text, not the app locale, like the web reader's dir='auto'.
val layoutDirection = when (direction) {
ContentDirection.LEFT_TO_RIGHT -> LayoutDirection.Ltr
ContentDirection.RIGHT_TO_LEFT -> LayoutDirection.Rtl
null -> LocalLayoutDirection.current
}
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
Column(
modifier = Modifier.widthIn(max = readerModeMaxContentWidth).fillMaxWidth()
.padding(horizontal = 32.dp).padding(top = 24.dp, bottom = 88.dp),
) {
ReaderArticleHeader(
title = title,
siteName = siteName,
fontSize = fontSize,
lineHeight = lineHeight,
)
if (body != null) body() else DelayedReaderSpinner(articleKey)
}
}
}
FeedFlowVerticalScrollbar(scrollState)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,32 @@ class DesktopReaderContentTest {
rule.onAllNodesWithText("<svg", substring = true, useUnmergedTree = true).assertCountEquals(0)
}

@Test
fun `right-to-left article lays out from the right edge in a left-to-right app`() {
val state = success()
showBody(
state.copy(
readerModeData = state.readerModeData.copy(title = RTL_TITLE, content = RTL_BODY),
),
)
rule.waitUntil(timeoutMillis = 5_000) {
rule.onAllNodesWithText(RTL_BODY, substring = true).fetchSemanticsNodes().isNotEmpty()
}

val document = rule.onNodeWithTag(READER_DOCUMENT_TAG).fetchSemanticsNode().boundsInRoot
listOf(RTL_TITLE, RTL_BODY).forEach { text ->
val bounds = rule.onNodeWithText(text, substring = true).fetchSemanticsNode().boundsInRoot
assertTrue(
bounds.right - bounds.left < document.width / 2,
"Fixture text must stay on one short line, got $bounds in $document",
)
assertTrue(
bounds.center.x > document.center.x,
"Right-to-left text must start at the right edge, got $bounds in $document",
)
}
}

private fun showBody(state: ReaderModeState, onOpenInBrowser: (String) -> Unit = {}) {
rule.setContent {
CompositionLocalProvider(LocalFeedFlowStrings provides EnFeedFlowStrings) {
Expand Down Expand Up @@ -216,6 +242,8 @@ class DesktopReaderContentTest {
private companion object {
const val BODY_TEXT = "Content retained from the feed."
const val ARTICLE_URL = "https://example.com/article"
const val RTL_TITLE = "خبر فوری"
const val RTL_BODY = "متن کوتاه مقاله"
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.prof18.feedflow.shared.domain

import com.prof18.feedflow.core.utils.ContentDirection
import com.prof18.feedflow.core.utils.ContentDirectionDetector

/**
* Builds the Markdown document shown by the Desktop reader: the Markdown counterpart of
* [getReaderModeStyledHtml]. [imageUrl] is added as a hero only when the content does not
Expand Down Expand Up @@ -34,3 +37,34 @@ private val leadingImageRegex = Regex(

private fun hasLeadingMarkdownImage(content: String): Boolean =
leadingImageRegex.containsMatchIn(content.take(LEADING_IMAGE_SCAN_WINDOW))

/**
* Resolves the reading direction of a Desktop reader article, the Markdown counterpart of the
* `dir='auto'` that [getReaderModeStyledHtml] puts on the web reader: the first strong character
* of the title, site name and body decides. Image and link destinations, HTML tags and bare URLs
* are skipped, so a body opening with a hero image is still read from its first word.
*/
fun readerModeContentDirection(
title: String?,
siteName: String?,
markdown: String?,
): ContentDirection? = ContentDirectionDetector.detect(
listOfNotNull(title, siteName, markdown?.take(DIRECTION_SCAN_WINDOW)?.withoutMarkupForDirection()),
)

private fun String.withoutMarkupForDirection(): String = directionMarkupRegexes.fold(this) { text, regex ->
regex.replace(text, " ")
}

private const val DIRECTION_SCAN_WINDOW = 8000

// A link destination as Klead writes it: parentheses escaped, wrapped in <> when it has spaces,
// optionally followed by a quoted title with escaped quotes.
private const val MARKDOWN_DESTINATION =
"""\((?:<(?:\\.|[^>\\])*>|(?:\\.|[^)\\\s])*)(?:\s+"(?:\\.|[^"\\])*")?\)"""
private val directionMarkupRegexes = listOf(
Regex("""!\[[^]]*]$MARKDOWN_DESTINATION"""),
Regex("""]$MARKDOWN_DESTINATION"""),
Regex("<[^>]*>"),
Regex("https?://\\S+", RegexOption.IGNORE_CASE),
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.prof18.feedflow.shared.domain

import com.prof18.feedflow.core.utils.ContentDirection
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class ReaderModeMarkdownTest {

Expand Down Expand Up @@ -72,4 +74,48 @@ class ReaderModeMarkdownTest {
markdown,
)
}

@Test
fun `reader direction follows a right-to-left title`() {
assertEquals(
ContentDirection.RIGHT_TO_LEFT,
readerModeContentDirection(title = "خبر فوری", siteName = null, markdown = "Body"),
)
}

@Test
fun `reader direction skips a neutral title and markup before the first word of the body`() {
val markdown = "![hero](https://example.com/hero.jpg)\n\n" +
"<p class=\"lead\">[https://example.com](https://example.com) متن مقاله</p>"

assertEquals(
ContentDirection.RIGHT_TO_LEFT,
readerModeContentDirection(title = "2026 — 12:45", siteName = null, markdown = markdown),
)
}

@Test
fun `reader direction stays left-to-right for a Latin article`() {
assertEquals(
ContentDirection.LEFT_TO_RIGHT,
readerModeContentDirection(title = "Article title", siteName = null, markdown = "متن"),
)
}

@Test
fun `reader direction is unknown without any strong character`() {
assertNull(readerModeContentDirection(title = null, siteName = null, markdown = "![](https://x.com/a.jpg) 42"))
}

@Test
fun `reader direction skips escaped parentheses angle brackets and titles in Klead destinations`() {
val markdown = "![hero](https://example.com/photo\\(1\\).jpg)\n\n" +
"![](<https://example.com/my photo.jpg> \"Latin \\\"title\\\"\")\n\n" +
"[](https://example.com/wiki/Iran_\\(country\\) \"Wiki\") متن مقاله"

assertEquals(
ContentDirection.RIGHT_TO_LEFT,
readerModeContentDirection(title = null, siteName = null, markdown = markdown),
)
}
}
Loading