feat: revert back to old text pipeline

Things have been entirely too slow on the lobste.rs front and I don't care much to continue pushing for it.

Fixes #382
Fixes #383
This commit is contained in:
Harsh Shandilya 2023-06-28 10:58:39 +05:30
parent c089e5ae5a
commit b5c57500b1
No known key found for this signature in database
16 changed files with 171 additions and 229 deletions

View file

@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
* Comments and user profile text rendering was overhauled, there should be no visual changes
* Added another workaround for native library loading crash
## [1.29.0] - 2023-06-08

View file

@ -70,6 +70,7 @@ dependencies {
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.coil)
implementation(libs.copydown)
implementation(libs.crux)
implementation(libs.dagger)
implementation(libs.jsoup)

View file

@ -18,6 +18,7 @@ import androidx.core.view.WindowCompat
import com.deliveryhero.whetstone.Whetstone
import com.deliveryhero.whetstone.activity.ContributesActivityInjector
import dev.msfjarvis.claw.android.ui.LobstersApp
import dev.msfjarvis.claw.common.comments.HTMLConverter
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import javax.inject.Inject
@ -25,6 +26,7 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() {
@Inject lateinit var urlLauncher: UrlLauncher
@Inject lateinit var htmlConverter: HTMLConverter
private var webUri: String? = null
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@ -38,6 +40,7 @@ class MainActivity : ComponentActivity() {
LobstersApp(
urlLauncher = urlLauncher,
htmlConverter = htmlConverter,
windowSizeClass = windowSizeClass,
setWebUri = { url -> webUri = url },
)

View file

@ -0,0 +1,20 @@
/*
* Copyright © 2021-2023 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.injection
import com.deliveryhero.whetstone.app.ApplicationScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dev.msfjarvis.claw.android.ui.util.HTMLConverterImpl
import dev.msfjarvis.claw.common.comments.HTMLConverter
@Module
@ContributesTo(ApplicationScope::class)
interface HTMLConverterModule {
@Binds fun HTMLConverterImpl.bind(): HTMLConverter
}

View file

@ -64,6 +64,7 @@ import dev.msfjarvis.claw.android.ui.navigation.Destinations
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.comments.HTMLConverter
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.decorations.ClawAppBar
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
@ -76,6 +77,7 @@ import kotlinx.coroutines.launch
@Composable
fun LobstersApp(
urlLauncher: UrlLauncher,
htmlConverter: HTMLConverter,
windowSizeClass: WindowSizeClass,
setWebUri: (String?) -> Unit,
modifier: Modifier = Modifier,
@ -236,6 +238,7 @@ fun LobstersApp(
CommentsPage(
postId = postId,
postActions = postActions,
htmlConverter = htmlConverter,
getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments,
)

View file

@ -0,0 +1,21 @@
/*
* Copyright © 2023 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.ui.util
import androidx.compose.runtime.Stable
import dev.msfjarvis.claw.common.comments.HTMLConverter
import io.github.furstenheim.CopyDown
import javax.inject.Inject
@Stable
class HTMLConverterImpl @Inject constructor() : HTMLConverter {
private val copydown = CopyDown()
override fun convertHTMLToMarkdown(html: String): String {
return copydown.convert(html)
}
}

View file

@ -45,6 +45,9 @@ dependencies {
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.ui.text)
implementation(libs.coil.compose)
implementation(libs.compose.richtext.markdown)
implementation(libs.compose.richtext.material3)
implementation(libs.compose.richtext.ui)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.napier)

View file

@ -41,8 +41,8 @@ import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.PostTitle
import dev.msfjarvis.claw.common.posts.Submitter
import dev.msfjarvis.claw.common.posts.TagRow
import dev.msfjarvis.claw.common.ui.HTMLText
import dev.msfjarvis.claw.common.ui.NetworkImage
import dev.msfjarvis.claw.common.ui.ThemedRichText
import dev.msfjarvis.claw.model.LinkMetadata
import dev.msfjarvis.claw.model.LobstersPostDetails
import java.time.Instant
@ -53,6 +53,7 @@ import kotlinx.collections.immutable.toImmutableList
internal fun CommentsHeader(
postDetails: LobstersPostDetails,
postActions: PostActions,
htmlConverter: HTMLConverter,
modifier: Modifier = Modifier,
) {
val uriHandler = LocalUriHandler.current
@ -83,7 +84,7 @@ internal fun CommentsHeader(
}
if (postDetails.description.isNotBlank()) {
HTMLText(postDetails.description)
ThemedRichText(htmlConverter.convertHTMLToMarkdown(postDetails.description))
Spacer(Modifier.height(4.dp))
}
Submitter(
@ -133,6 +134,7 @@ private val CommentEntryPadding = 16f.dp
@Composable
internal fun CommentEntry(
commentNode: CommentNode,
htmlConverter: HTMLConverter,
toggleExpanded: (CommentNode) -> Unit,
modifier: Modifier = Modifier,
) {
@ -167,7 +169,10 @@ internal fun CommentEntry(
Modifier.clickable { uriHandler.openUri("https://lobste.rs/u/${comment.user.username}") },
)
if (commentNode.isExpanded) {
HTMLText(text = comment.comment, modifier = Modifier.padding(top = 8.dp))
ThemedRichText(
text = htmlConverter.convertHTMLToMarkdown(comment.comment),
modifier = Modifier.padding(top = 8.dp)
)
}
}
}

View file

@ -68,11 +68,13 @@ internal fun findTopMostParent(node: CommentNode): CommentNode {
internal fun LazyListScope.nodes(
nodes: List<CommentNode>,
htmlConverter: HTMLConverter,
toggleExpanded: (CommentNode) -> Unit,
) {
nodes.forEach { node ->
node(
node = node,
htmlConverter = htmlConverter,
toggleExpanded = toggleExpanded,
)
}
@ -80,6 +82,7 @@ internal fun LazyListScope.nodes(
private fun LazyListScope.node(
node: CommentNode,
htmlConverter: HTMLConverter,
toggleExpanded: (CommentNode) -> Unit,
) {
// Skip the node if neither the node nor its parent is expanded
@ -89,6 +92,7 @@ private fun LazyListScope.node(
item {
CommentEntry(
commentNode = node,
htmlConverter = htmlConverter,
toggleExpanded = toggleExpanded,
)
Divider()
@ -96,6 +100,7 @@ private fun LazyListScope.node(
if (node.children.isNotEmpty()) {
nodes(
node.children,
htmlConverter = htmlConverter,
toggleExpanded = toggleExpanded,
)
}

View file

@ -41,6 +41,7 @@ import dev.msfjarvis.claw.model.LobstersPostDetails
private fun CommentsPageInternal(
details: LobstersPostDetails,
postActions: PostActions,
htmlConverter: HTMLConverter,
commentState: PostComments?,
markSeenComments: (String, List<Comment>) -> Unit,
modifier: Modifier = Modifier,
@ -54,6 +55,7 @@ private fun CommentsPageInternal(
CommentsHeader(
postDetails = details,
postActions = postActions,
htmlConverter = htmlConverter,
)
}
@ -68,6 +70,7 @@ private fun CommentsPageInternal(
nodes(
nodes = commentNodes,
htmlConverter = htmlConverter,
toggleExpanded = { node ->
val newNode = setExpanded(node, !node.isExpanded)
val parent = findTopMostParent(newNode)
@ -99,6 +102,7 @@ private fun CommentsPageInternal(
fun CommentsPage(
postId: String,
postActions: PostActions,
htmlConverter: HTMLConverter,
getSeenComments: suspend (String) -> PostComments?,
markSeenComments: (String, List<Comment>) -> Unit,
modifier: Modifier = Modifier,
@ -119,6 +123,7 @@ fun CommentsPage(
CommentsPageInternal(
details = (postDetails as Success<LobstersPostDetails>).data,
postActions = postActions,
htmlConverter = htmlConverter,
commentState = commentState,
markSeenComments = markSeenComments,
modifier = modifier.fillMaxSize(),

View file

@ -0,0 +1,15 @@
/*
* Copyright © 2023 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.comments
import androidx.compose.runtime.Stable
/** Defines a contract to convert strings of HTML to Markdown. */
@Stable
fun interface HTMLConverter {
fun convertHTMLToMarkdown(html: String): String
}

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidedValue
import androidx.compose.ui.platform.LocalContext
import com.halilibo.richtext.ui.material3.SetupMaterial3RichText
private val LightThemeColors =
lightColorScheme(
@ -97,6 +98,8 @@ fun LobstersTheme(
else -> if (darkTheme) DarkThemeColors else LightThemeColors
}
CompositionLocalProvider(*providedValues) {
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
MaterialTheme(colorScheme = colorScheme, typography = AppTypography) {
SetupMaterial3RichText { content() }
}
}
}

View file

@ -1,220 +0,0 @@
/*
* Copyright © 2023 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.ui
import android.graphics.Typeface
import android.text.Spanned
import android.text.style.BulletSpan
import android.text.style.ForegroundColorSpan
import android.text.style.QuoteSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.text.style.SubscriptSpan
import android.text.style.SuperscriptSpan
import android.text.style.URLSpan
import android.text.style.UnderlineSpan
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.core.text.HtmlCompat
import dev.msfjarvis.claw.common.theme.LobstersTheme
private const val URL_TAG = "url_tag"
@Composable
fun HTMLText(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.bodyLarge,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
fontSize: TextUnit = 14.sp,
flags: Int = HtmlCompat.FROM_HTML_MODE_COMPACT,
customSpannedHandler: ((Spanned) -> AnnotatedString)? = null
) {
val content = text.asHTML(fontSize, flags, customSpannedHandler)
val uriHandler = LocalUriHandler.current
ClickableText(
modifier = modifier,
text = content,
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = onTextLayout,
onClick = { offset ->
content.getStringAnnotations(URL_TAG, offset, offset).firstOrNull()?.let { stringAnnotation ->
uriHandler.openUri(stringAnnotation.item)
}
}
)
}
@Composable
private fun String.asHTML(
fontSize: TextUnit,
flags: Int,
customSpannedHandler: ((Spanned) -> AnnotatedString)? = null
) = buildAnnotatedString {
val spanned = HtmlCompat.fromHtml(this@asHTML, flags)
val spans = spanned.getSpans(0, spanned.length, Any::class.java)
if (customSpannedHandler != null) {
append(customSpannedHandler(spanned))
} else {
append(spanned.toString())
}
spans
.filter { it !is BulletSpan }
.forEach { span ->
val start = spanned.getSpanStart(span)
val end = spanned.getSpanEnd(span)
when (span) {
is RelativeSizeSpan -> span.spanStyle(fontSize)
is StyleSpan -> span.spanStyle()
is UnderlineSpan -> span.spanStyle()
is ForegroundColorSpan -> span.spanStyle()
is StrikethroughSpan -> span.spanStyle()
is SuperscriptSpan -> span.spanStyle()
is SubscriptSpan -> span.spanStyle()
is QuoteSpan -> span.spanStyle()
is URLSpan -> {
addStringAnnotation(tag = URL_TAG, annotation = span.url, start = start, end = end)
span.spanStyle()
}
else -> {
null
}
}?.let { spanStyle -> addStyle(spanStyle, start, end) }
}
}
@Suppress("UnusedReceiverParameter")
@Composable
private fun URLSpan.spanStyle(): SpanStyle =
SpanStyle(
background = MaterialTheme.colorScheme.surfaceVariant,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline,
)
@Suppress("UnusedReceiverParameter")
private fun UnderlineSpan.spanStyle(): SpanStyle =
SpanStyle(textDecoration = TextDecoration.Underline)
private fun ForegroundColorSpan.spanStyle(): SpanStyle = SpanStyle(color = Color(foregroundColor))
@Suppress("UnusedReceiverParameter")
private fun StrikethroughSpan.spanStyle(): SpanStyle =
SpanStyle(textDecoration = TextDecoration.LineThrough)
private fun RelativeSizeSpan.spanStyle(fontSize: TextUnit): SpanStyle =
SpanStyle(fontSize = (fontSize.value * sizeChange).sp)
@Suppress("UnusedReceiverParameter")
private fun QuoteSpan.spanStyle(): SpanStyle = SpanStyle(fontStyle = FontStyle.Italic)
private fun StyleSpan.spanStyle(): SpanStyle? =
when (style) {
Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
Typeface.BOLD_ITALIC ->
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
)
else -> null
}
@Suppress("UnusedReceiverParameter")
private fun SubscriptSpan.spanStyle(): SpanStyle =
SpanStyle(baselineShift = BaselineShift.Subscript)
@Suppress("UnusedReceiverParameter")
private fun SuperscriptSpan.spanStyle(): SpanStyle =
SpanStyle(baselineShift = BaselineShift.Superscript)
@Composable
private fun ClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onClick: (Int) -> Unit
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator =
Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult -> onClick(layoutResult.getOffsetForPosition(pos)) }
}
}
Text(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
}
)
}
@Preview
@Composable
fun HTMLTextPreview() {
LobstersTheme {
Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
HTMLText(
text =
"""
<h3 id="heading">Heading</h3>
<p>This is a paragraph body</p>
<pre><code>This is <span class="hljs-selector-tag">a</span> <span class="hljs-selector-tag">code</span> block
</code></pre><p>This is an <code>inline code block</code></p>
<blockquote><p>This is a blockquote</p></blockquote>
<p><a href="https://github.com/msfjarvis/compose-lobsters">This is a link</a></p>
<p><img src="https://avatars.githubusercontent.com/u/13348378?v=4" alt="Image"></p>
"""
.trimIndent()
)
}
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright © 2023 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.ui
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.ui.RichText
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.string.RichTextStringStyle
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
@Composable
internal fun ThemedRichText(
text: String,
modifier: Modifier = Modifier,
) {
val linkStyle =
SpanStyle(
background = MaterialTheme.colorScheme.surfaceVariant,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline,
)
val stringStyle = RichTextStringStyle.Default.copy(linkStyle = linkStyle)
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodyLarge,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
RichText(
modifier = modifier,
style = RichTextStyle.Default.copy(stringStyle = stringStyle),
) {
Markdown(text)
}
}
}
@ThemePreviews
@Composable
internal fun ThemedRichTextPreview() {
val text =
"""
### Heading
This is a paragraph body
```
This is a code block
```
This is an `inline code block`
[This is a link](https://github.com/msfjarvis/compose-lobsters)
![Image](https://avatars.githubusercontent.com/u/13348378?v=4)
"""
.trimIndent()
LobstersTheme { ThemedRichText(text = text) }
}

View file

@ -29,10 +29,10 @@ import dev.msfjarvis.claw.common.NetworkState
import dev.msfjarvis.claw.common.NetworkState.Error
import dev.msfjarvis.claw.common.NetworkState.Loading
import dev.msfjarvis.claw.common.NetworkState.Success
import dev.msfjarvis.claw.common.ui.HTMLText
import dev.msfjarvis.claw.common.ui.NetworkError
import dev.msfjarvis.claw.common.ui.NetworkImage
import dev.msfjarvis.claw.common.ui.ProgressBar
import dev.msfjarvis.claw.common.ui.ThemedRichText
import dev.msfjarvis.claw.model.User
@Suppress("UNCHECKED_CAST")
@ -99,10 +99,12 @@ private fun UserProfileInternal(
text = user.username,
style = MaterialTheme.typography.displaySmall,
)
HTMLText(text = user.about)
ThemedRichText(
text = user.about,
)
user.invitedBy?.let { invitedBy ->
HTMLText(
text = """Invited by <a href="https://lobste.rs/u/${invitedBy}">${invitedBy}</a>""",
ThemedRichText(
text = "Invited by [${invitedBy}](https://lobste.rs/u/${user.invitedBy})",
)
}
}

View file

@ -9,6 +9,7 @@ dagger = "2.46.1"
junit = "5.9.3"
kotlin = "1.8.10"
retrofit = "2.9.0"
richtext = "0.16.0"
sentry-sdk = "6.24.0"
serialization = "1.5.1"
sqldelight = "2.0.0-rc02"
@ -56,6 +57,10 @@ build-vcu = "nl.littlerobots.version-catalog-update:nl.littlerobots.version-cata
build-versions = "com.github.ben-manes:gradle-versions-plugin:0.47.0"
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
compose-richtext-markdown = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" }
compose-richtext-material3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "richtext" }
compose-richtext-ui = { module = "com.halilibo.compose-richtext:richtext-ui", version.ref = "richtext" }
copydown = "io.github.furstenheim:copy_down:1.1"
crux = "com.chimbori.crux:crux:3.12.0"
dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" }
dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" }