refactor: rewrite comment text rendering

This commit is contained in:
Harsh Shandilya 2025-02-04 20:04:51 +05:30
parent 77da21d9b0
commit be80812dc6
18 changed files with 243 additions and 177 deletions

View file

@ -110,7 +110,6 @@ dependencies {
implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
implementation(libs.copydown)
implementation(libs.dagger) implementation(libs.dagger)
implementation(libs.eithernet) implementation(libs.eithernet)
implementation(libs.haze) implementation(libs.haze)

View file

@ -19,7 +19,6 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.net.toUri import androidx.core.net.toUri
import com.deliveryhero.whetstone.Whetstone import com.deliveryhero.whetstone.Whetstone
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.comments.HTMLConverter
import dev.msfjarvis.claw.common.theme.LobstersTheme import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import javax.inject.Inject import javax.inject.Inject
@ -29,7 +28,6 @@ import javax.inject.Inject
abstract class BaseActivity : ComponentActivity() { abstract class BaseActivity : ComponentActivity() {
@Inject lateinit var urlLauncher: UrlLauncher @Inject lateinit var urlLauncher: UrlLauncher
@Inject lateinit var htmlConverter: HTMLConverter
@Inject lateinit var viewModel: ClawViewModel @Inject lateinit var viewModel: ClawViewModel
var webUri: String? = null var webUri: String? = null

View file

@ -29,18 +29,13 @@ class MainActivity : BaseActivity() {
WindowWidthSizeClass.Compact -> { WindowWidthSizeClass.Compact -> {
LobstersPostsScreen( LobstersPostsScreen(
urlLauncher = urlLauncher, urlLauncher = urlLauncher,
htmlConverter = htmlConverter,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
setWebUri = { url -> webUri = url }, setWebUri = { url -> webUri = url },
) )
} }
else -> { else -> {
TabletScreen( TabletScreen(urlLauncher = urlLauncher, modifier = Modifier.fillMaxSize())
urlLauncher = urlLauncher,
htmlConverter = htmlConverter,
modifier = Modifier.fillMaxSize(),
)
} }
} }
} }

View file

@ -14,11 +14,6 @@ import dev.msfjarvis.claw.android.ui.screens.SearchScreen
class SearchActivity : BaseActivity() { class SearchActivity : BaseActivity() {
@Composable @Composable
override fun Content() { override fun Content() {
SearchScreen( SearchScreen(urlLauncher = urlLauncher, setWebUri = { webUri = it }, viewModel = viewModel)
urlLauncher = urlLauncher,
htmlConverter = htmlConverter,
setWebUri = { webUri = it },
viewModel = viewModel,
)
} }
} }

View file

@ -1,20 +0,0 @@
/*
* Copyright © 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 bindHTMLConverter(impl: HTMLConverterImpl): HTMLConverter
}

View file

@ -76,7 +76,6 @@ import dev.msfjarvis.claw.android.ui.navigation.any
import dev.msfjarvis.claw.android.ui.navigation.none import dev.msfjarvis.claw.android.ui.navigation.none
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.comments.HTMLConverter
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import dev.msfjarvis.claw.common.user.UserProfile import dev.msfjarvis.claw.common.user.UserProfile
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -88,7 +87,6 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun LobstersPostsScreen( fun LobstersPostsScreen(
urlLauncher: UrlLauncher, urlLauncher: UrlLauncher,
htmlConverter: HTMLConverter,
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
setWebUri: (String?) -> Unit, setWebUri: (String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -244,7 +242,6 @@ fun LobstersPostsScreen(
CommentsPage( CommentsPage(
postId = postId, postId = postId,
postActions = postActions, postActions = postActions,
htmlConverter = htmlConverter,
getSeenComments = viewModel::getSeenComments, getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments, markSeenComments = viewModel::markSeenComments,
contentPadding = contentPadding, contentPadding = contentPadding,

View file

@ -25,14 +25,12 @@ import dev.msfjarvis.claw.android.ui.navigation.Search
import dev.msfjarvis.claw.android.ui.navigation.User import dev.msfjarvis.claw.android.ui.navigation.User
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.comments.HTMLConverter
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import dev.msfjarvis.claw.common.user.UserProfile import dev.msfjarvis.claw.common.user.UserProfile
@Composable @Composable
fun SearchScreen( fun SearchScreen(
urlLauncher: UrlLauncher, urlLauncher: UrlLauncher,
htmlConverter: HTMLConverter,
setWebUri: (String?) -> Unit, setWebUri: (String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(), viewModel: ClawViewModel = injectedViewModel(),
@ -64,11 +62,10 @@ fun SearchScreen(
CommentsPage( CommentsPage(
postId = postId, postId = postId,
postActions = postActions, postActions = postActions,
htmlConverter = htmlConverter,
getSeenComments = viewModel::getSeenComments, getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments, markSeenComments = viewModel::markSeenComments,
openUserProfile = { navController.navigate(User(it)) },
contentPadding = contentPadding, contentPadding = contentPadding,
openUserProfile = { navController.navigate(User(it)) },
) )
} }
composable<User> { backStackEntry -> composable<User> { backStackEntry ->

View file

@ -66,7 +66,6 @@ import dev.msfjarvis.claw.android.ui.navigation.Saved
import dev.msfjarvis.claw.android.ui.navigation.User import dev.msfjarvis.claw.android.ui.navigation.User
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.comments.HTMLConverter
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
@ -82,7 +81,6 @@ private fun ThreePaneScaffoldNavigator<*>.isDetailExpanded() =
@Composable @Composable
fun TabletScreen( fun TabletScreen(
urlLauncher: UrlLauncher, urlLauncher: UrlLauncher,
htmlConverter: HTMLConverter,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(), viewModel: ClawViewModel = injectedViewModel(),
) { ) {
@ -219,12 +217,11 @@ fun TabletScreen(
CommentsPage( CommentsPage(
postId = contentKey.postId, postId = contentKey.postId,
postActions = postActions, postActions = postActions,
htmlConverter = htmlConverter,
getSeenComments = viewModel::getSeenComments, getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments, markSeenComments = viewModel::markSeenComments,
openUserProfile = { navController.navigate(User(it)) },
contentPadding = PaddingValues(), contentPadding = PaddingValues(),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
openUserProfile = { navController.navigate(User(it)) },
) )
} }
} }

View file

@ -40,7 +40,6 @@ import dev.msfjarvis.claw.android.ui.navigation.Settings
import dev.msfjarvis.claw.android.ui.navigation.User import dev.msfjarvis.claw.android.ui.navigation.User
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.comments.HTMLConverter
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import dev.msfjarvis.claw.common.user.UserProfile import dev.msfjarvis.claw.common.user.UserProfile
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
@ -48,7 +47,6 @@ import kotlinx.collections.immutable.persistentMapOf
@Composable @Composable
fun TabletScreen2( fun TabletScreen2(
urlLauncher: UrlLauncher, urlLauncher: UrlLauncher,
htmlConverter: HTMLConverter,
setWebUri: (String?) -> Unit, setWebUri: (String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(), viewModel: ClawViewModel = injectedViewModel(),
@ -140,7 +138,6 @@ fun TabletScreen2(
CommentsPage( CommentsPage(
postId = postId, postId = postId,
postActions = postActions, postActions = postActions,
htmlConverter = htmlConverter,
getSeenComments = viewModel::getSeenComments, getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments, markSeenComments = viewModel::markSeenComments,
contentPadding = contentPadding, contentPadding = contentPadding,

View file

@ -1,21 +0,0 @@
/*
* Copyright © 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

@ -59,6 +59,7 @@ dependencies {
implementation(libs.compose.richtext.markdown) implementation(libs.compose.richtext.markdown)
implementation(libs.compose.richtext.material3) implementation(libs.compose.richtext.material3)
implementation(libs.compose.richtext.ui) implementation(libs.compose.richtext.ui)
implementation(libs.htmlconverter)
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinResult) implementation(libs.kotlinResult)

View file

@ -46,7 +46,6 @@ import kotlinx.collections.immutable.toImmutableList
internal fun CommentsHeader( internal fun CommentsHeader(
post: UIPost, post: UIPost,
postActions: PostActions, postActions: PostActions,
htmlConverter: HTMLConverter,
openUserProfile: (String) -> Unit, openUserProfile: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -77,7 +76,7 @@ internal fun CommentsHeader(
} }
if (post.description.isNotBlank()) { if (post.description.isNotBlank()) {
ThemedRichText(htmlConverter.convertHTMLToMarkdown(post.description)) ThemedRichText(post.description)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
Submitter( Submitter(

View file

@ -32,7 +32,6 @@ import dev.msfjarvis.claw.model.UIPost
fun CommentsPage( fun CommentsPage(
postId: String, postId: String,
postActions: PostActions, postActions: PostActions,
htmlConverter: HTMLConverter,
getSeenComments: suspend (String) -> PostComments?, getSeenComments: suspend (String) -> PostComments?,
markSeenComments: (String, List<Comment>) -> Unit, markSeenComments: (String, List<Comment>) -> Unit,
contentPadding: PaddingValues, contentPadding: PaddingValues,
@ -57,7 +56,6 @@ fun CommentsPage(
CommentsPageInternal( CommentsPageInternal(
details = (postDetails as Success<UIPost>).data, details = (postDetails as Success<UIPost>).data,
postActions = postActions, postActions = postActions,
htmlConverter = htmlConverter,
commentState = commentState, commentState = commentState,
markSeenComments = markSeenComments, markSeenComments = markSeenComments,
openUserProfile = openUserProfile, openUserProfile = openUserProfile,

View file

@ -63,7 +63,6 @@ private const val AnimationDuration = 100
internal fun CommentsPageInternal( internal fun CommentsPageInternal(
details: UIPost, details: UIPost,
postActions: PostActions, postActions: PostActions,
htmlConverter: HTMLConverter,
commentState: PostComments?, commentState: PostComments?,
markSeenComments: (String, List<Comment>) -> Unit, markSeenComments: (String, List<Comment>) -> Unit,
openUserProfile: (String) -> Unit, openUserProfile: (String) -> Unit,
@ -103,12 +102,7 @@ internal fun CommentsPageInternal(
Surface(color = MaterialTheme.colorScheme.surfaceVariant) { Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
LazyColumn(modifier = modifier, contentPadding = contentPadding, state = commentListState) { LazyColumn(modifier = modifier, contentPadding = contentPadding, state = commentListState) {
item { item {
CommentsHeader( CommentsHeader(post = details, postActions = postActions, openUserProfile = openUserProfile)
post = details,
postActions = postActions,
htmlConverter = htmlConverter,
openUserProfile = openUserProfile,
)
} }
if (commentNodes.isNotEmpty()) { if (commentNodes.isNotEmpty()) {
@ -121,7 +115,7 @@ internal fun CommentsPageInternal(
} }
items(items = commentNodes, key = { node -> node.comment.shortId }) { node -> items(items = commentNodes, key = { node -> node.comment.shortId }) { node ->
Node(node, htmlConverter, openUserProfile, onToggleExpandedState) Node(node, openUserProfile, onToggleExpandedState)
} }
item(key = "bottom_spacer") { item(key = "bottom_spacer") {
@ -149,7 +143,6 @@ internal fun CommentsPageInternal(
@Composable @Composable
private fun Node( private fun Node(
node: CommentNode, node: CommentNode,
htmlConverter: HTMLConverter,
openUserProfile: (String) -> Unit, openUserProfile: (String) -> Unit,
onToggleExpandedState: (String, Boolean) -> Unit, onToggleExpandedState: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -158,7 +151,6 @@ private fun Node(
NodeBox( NodeBox(
node = node, node = node,
isExpanded = node.isExpanded, isExpanded = node.isExpanded,
htmlConverter = htmlConverter,
openUserProfile, openUserProfile,
modifier = modifier =
modifier.clickable( modifier.clickable(
@ -172,9 +164,7 @@ private fun Node(
exit = fadeOut(tween(AnimationDuration)) + shrinkVertically(tween(AnimationDuration)), exit = fadeOut(tween(AnimationDuration)) + shrinkVertically(tween(AnimationDuration)),
) { ) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
node.children.forEach { model -> node.children.forEach { model -> Node(model, openUserProfile, onToggleExpandedState) }
Node(model, htmlConverter, openUserProfile, onToggleExpandedState)
}
} }
} }
} }
@ -184,11 +174,10 @@ private fun Node(
private fun NodeBox( private fun NodeBox(
node: CommentNode, node: CommentNode,
isExpanded: Boolean, isExpanded: Boolean,
htmlConverter: HTMLConverter,
openUserProfile: (String) -> Unit, openUserProfile: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
CommentEntry(isExpanded, node, htmlConverter, openUserProfile, modifier) CommentEntry(isExpanded, node, openUserProfile, modifier)
HorizontalDivider() HorizontalDivider()
} }
@ -198,7 +187,6 @@ private val CommentEntryPadding = 16f.dp
private fun CommentEntry( private fun CommentEntry(
isExpanded: Boolean, isExpanded: Boolean,
commentNode: CommentNode, commentNode: CommentNode,
htmlConverter: HTMLConverter,
openUserProfile: (String) -> Unit, openUserProfile: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -234,10 +222,7 @@ private fun CommentEntry(
modifier = Modifier.clickable { openUserProfile(comment.user) }, modifier = Modifier.clickable { openUserProfile(comment.user) },
) )
if (isExpanded) { if (isExpanded) {
ThemedRichText( ThemedRichText(text = comment.comment, modifier = Modifier.padding(top = 8.dp))
text = htmlConverter.convertHTMLToMarkdown(comment.comment),
modifier = Modifier.padding(top = 8.dp),
)
} }
} }
} }

View file

@ -1,15 +0,0 @@
/*
* Copyright © 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

@ -0,0 +1,231 @@
/*
* Copyright © 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.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import be.digitalia.compose.htmlconverter.HtmlStyle
import be.digitalia.compose.htmlconverter.htmlToAnnotatedString
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
private sealed class Segment {
data class Text(val content: String) : Segment()
data class Blockquote(val content: String) : Segment()
data class Code(val content: String) : Segment()
}
private fun parseSegments(text: String): List<Segment> {
val segments = mutableListOf<Segment>()
var i = 0
val blockquoteStart = "<blockquote>"
val blockquoteEnd = "</blockquote>"
val preStart = "<pre>"
val preEnd = "</pre>"
val codeStart = "<code"
val codeEnd = "</code>"
fun addSegment(content: String, isBlockquote: Boolean = false, isCode: Boolean = false) {
if (content.isNotBlank()) {
when {
isBlockquote -> segments.add(Segment.Blockquote(content))
isCode -> segments.add(Segment.Code(content))
else -> segments.add(Segment.Text(content))
}
}
}
while (i < text.length) {
val nextBlockquote = text.indexOf(blockquoteStart, i, ignoreCase = true)
val nextPre = text.indexOf(preStart, i, ignoreCase = true)
val (nextTag, isBlockquote) =
when {
nextBlockquote != -1 && (nextPre == -1 || nextBlockquote < nextPre) ->
nextBlockquote to true
nextPre != -1 -> nextPre to false
else -> -1 to false
}
if (nextTag == -1) {
addSegment(text.substring(i))
break
}
if (nextTag > i) {
addSegment(text.substring(i, nextTag))
}
if (isBlockquote) {
val endIdx = text.indexOf(blockquoteEnd, nextTag + blockquoteStart.length, ignoreCase = true)
if (endIdx == -1) {
addSegment(text.substring(nextTag))
break
}
addSegment(text.substring(nextTag + blockquoteStart.length, endIdx), isBlockquote = true)
i = endIdx + blockquoteEnd.length
} else {
val endIdx = text.indexOf(preEnd, nextTag + preStart.length, ignoreCase = true)
if (endIdx == -1) {
addSegment(text.substring(nextTag))
break
}
var codeContent = text.substring(nextTag + preStart.length, endIdx)
val codeTagStart = codeContent.indexOf(codeStart, ignoreCase = true)
val codeTagEnd = codeContent.indexOf(codeEnd, ignoreCase = true)
if (codeTagStart != -1 && codeTagEnd != -1 && codeTagStart < codeTagEnd) {
val codeOpenEnd = codeContent.indexOf('>', codeTagStart)
if (codeOpenEnd != -1 && codeOpenEnd < codeTagEnd) {
codeContent = codeContent.substring(codeOpenEnd + 1, codeTagEnd)
}
}
addSegment(codeContent, isCode = true)
i = endIdx + preEnd.length
}
}
return segments
}
@Composable
internal fun ThemedRichText(text: String, modifier: Modifier = Modifier) {
val linkSpanStyle =
SpanStyle(
background = MaterialTheme.colorScheme.surfaceVariant,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline,
)
val segments = parseSegments(text)
Column(modifier = modifier) {
for (segment in segments) {
when (segment) {
is Segment.Blockquote -> {
Row(
modifier = Modifier.padding(vertical = 4.dp).fillMaxWidth().height(IntrinsicSize.Min)
) {
Box(
modifier =
Modifier.width(4.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.7f))
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text =
remember(segment.content) {
htmlToAnnotatedString(
html = segment.content,
compactMode = false,
style = HtmlStyle(textLinkStyles = TextLinkStyles(linkSpanStyle)),
)
},
style = MaterialTheme.typography.bodyLarge,
color = contentColorFor(MaterialTheme.colorScheme.background),
)
}
}
}
is Segment.Code -> {
Box(
modifier =
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f),
shape = RoundedCornerShape(6.dp),
)
.padding(12.dp)
) {
Text(
text = segment.content,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is Segment.Text -> {
Text(
text =
remember(segment.content) {
htmlToAnnotatedString(
html = segment.content,
compactMode = false,
style = HtmlStyle(textLinkStyles = TextLinkStyles(linkSpanStyle)),
)
},
style = MaterialTheme.typography.bodyLarge,
color = contentColorFor(MaterialTheme.colorScheme.background),
)
}
}
}
}
}
@ThemePreviews
@Composable
internal fun ThemedRichTextPreview() {
val text =
"""
<h1>Hello <strong>HTML Converter</strong> for Compose</h1>
<p>This the first paragraph of the sample app running on <strong>Nothing</strong>!</p>
<ul>
<li><strong>Bold</strong></li>
<li><em>Italic</em></li>
<li><u>Underline</u></li>
<li><del>Strikethrough</del></li>
<li><code>Code</code></li>
<li><a href="https://www.wikipedia.org/">Hyperlink with custom styling</a></li>
<li><big>Bigger</big> and <small>smaller</small> text</li>
<li><sup>Super</sup>text and <sub>sub</sub>text</li>
<li>A nested ordered list:
<ol>
<li>Item 1</li>
<li>Item 2</li>
</ol>
</li>
</ul>
<dl>
<dt>Term</dt>
<dd>Description.</dd>
</dl>
A few HTML entities: &raquo; &copy; &laquo; &check;
<blockquote>A blockquote, indented relatively to the main text.</blockquote>
<pre><code class="language-toml">Preformatted text, preserving
line breaks... and spaces.</code></pre>
<p><code>Inline code example.</code> You reached the end of the document.<br />Thank you for reading!</p>
"""
.trimIndent()
LobstersTheme { ThemedRichText(text = text) }
}

View file

@ -1,67 +0,0 @@
/*
* Copyright © 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.TextLinkStyles
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import com.halilibo.richtext.commonmark.Markdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
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 linkSpanStyle =
SpanStyle(
background = MaterialTheme.colorScheme.surfaceVariant,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline,
)
val stringStyle = RichTextStringStyle(linkStyle = TextLinkStyles(linkSpanStyle))
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

@ -77,7 +77,6 @@ coil3-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", versio
compose-richtext-markdown = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" } 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-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" } compose-richtext-ui = { module = "com.halilibo.compose-richtext:richtext-ui", version.ref = "richtext" }
copydown = "io.github.furstenheim:copy_down:1.1"
dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" }
dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" } dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = "dagger" }
eithernet = { module = "com.slack.eithernet:eithernet", version.ref = "eithernet" } eithernet = { module = "com.slack.eithernet:eithernet", version.ref = "eithernet" }
@ -85,6 +84,7 @@ eithernet-integration-retrofit = { module = "com.slack.eithernet:eithernet-integ
eithernet-test-fixtures = { module = "com.slack.eithernet:eithernet-test-fixtures", version.ref = "eithernet" } eithernet-test-fixtures = { module = "com.slack.eithernet:eithernet-test-fixtures", version.ref = "eithernet" }
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" }
htmlconverter = "be.digitalia.compose.htmlconverter:htmlconverter:1.0.4"
javax-inject = "javax.inject:javax.inject:1" javax-inject = "javax.inject:javax.inject:1"
jsoup = "org.jsoup:jsoup:1.20.1" jsoup = "org.jsoup:jsoup:1.20.1"
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }