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

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

View file

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

View file

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