Skip to content

Commit 8fd7700

Browse files
authored
Merge pull request #71 from TypeType-Video/release/comment-html-beta-10
fix: render comment markup in 1.8.0-beta.10
2 parents a1806ab + 080d7da commit 8fd7700

6 files changed

Lines changed: 448 additions & 5 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ android {
2626
applicationId = "dev.typetype.android"
2727
minSdk = 23
2828
targetSdk = 37
29-
versionCode = 10809
30-
versionName = "1.8.0-beta.9"
29+
versionCode = 10810
30+
versionName = "1.8.0-beta.10"
3131
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3232
resValue("string", "app_name", "TypeType")
3333
}
@@ -140,6 +140,7 @@ dependencies {
140140
implementation(libs.androidx.activity.compose)
141141
implementation(libs.androidx.browser)
142142
implementation(libs.androidx.lifecycle.runtime.compose)
143+
implementation(libs.jsoup)
143144
implementation(libs.androidx.navigation.compose)
144145
debugImplementation(libs.androidx.compose.ui.test.manifest)
145146
debugImplementation(libs.androidx.compose.ui.tooling)

‎app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,12 @@ private fun ExpandableCommentText(
126126
onTimestampClick: (Long) -> Unit,
127127
) {
128128
var expanded by remember(comment.text) { mutableStateOf(false) }
129-
val needsTruncation = comment.text.length > COMMENT_COLLAPSE_CHARACTER_LIMIT ||
130-
comment.text.count { it == '\n' } >= COMMENT_COLLAPSE_LINE_LIMIT
129+
val renderedText = remember(comment.text) { richMarkupPlainText(comment.text) }
130+
val needsTruncation = renderedText.length > COMMENT_COLLAPSE_CHARACTER_LIMIT ||
131+
renderedText.count { it == '\n' } >= COMMENT_COLLAPSE_LINE_LIMIT
131132

132133
Column {
133-
LinkedText(
134+
CommentRichText(
134135
text = comment.text,
135136
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
136137
linkColor = MaterialTheme.colorScheme.primary,
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package dev.typetype.android.feature.player.components
2+
3+
import androidx.compose.material3.MaterialTheme
4+
import androidx.compose.material3.Text
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.State
7+
import androidx.compose.runtime.remember
8+
import androidx.compose.runtime.rememberUpdatedState
9+
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.graphics.Color
11+
import androidx.compose.ui.text.AnnotatedString
12+
import androidx.compose.ui.text.LinkAnnotation
13+
import androidx.compose.ui.text.SpanStyle
14+
import androidx.compose.ui.text.TextLinkStyles
15+
import androidx.compose.ui.text.TextStyle
16+
import androidx.compose.ui.text.buildAnnotatedString
17+
import androidx.compose.ui.text.font.FontFamily
18+
import androidx.compose.ui.text.font.FontStyle
19+
import androidx.compose.ui.text.font.FontWeight
20+
import androidx.compose.ui.text.style.TextDecoration
21+
import androidx.compose.ui.text.style.TextOverflow
22+
import androidx.compose.ui.text.withLink
23+
import androidx.compose.ui.text.withStyle
24+
25+
@Composable
26+
internal fun CommentRichText(
27+
text: String,
28+
style: TextStyle,
29+
linkColor: Color,
30+
onUrlClick: (String) -> Unit,
31+
onTimestampClick: (Long) -> Unit,
32+
modifier: Modifier = Modifier,
33+
maxLines: Int = Int.MAX_VALUE,
34+
overflow: TextOverflow = TextOverflow.Clip,
35+
) {
36+
val latestOnUrlClick = rememberUpdatedState(onUrlClick)
37+
val latestOnTimestampClick = rememberUpdatedState(onTimestampClick)
38+
val nodes = remember(text) { parseRichMarkup(text) }
39+
val linkStyles = remember(linkColor) {
40+
TextLinkStyles(
41+
style = SpanStyle(
42+
color = linkColor,
43+
textDecoration = TextDecoration.Underline,
44+
),
45+
)
46+
}
47+
val highlightBackground = MaterialTheme.colorScheme.surfaceVariant
48+
val formatStyles = remember(highlightBackground) {
49+
mapOf(
50+
RichMarkupFormat.Strong to SpanStyle(fontWeight = FontWeight.Bold),
51+
RichMarkupFormat.Emphasized to SpanStyle(fontStyle = FontStyle.Italic),
52+
RichMarkupFormat.Underline to SpanStyle(textDecoration = TextDecoration.Underline),
53+
RichMarkupFormat.Strikethrough to SpanStyle(textDecoration = TextDecoration.LineThrough),
54+
RichMarkupFormat.Code to SpanStyle(fontFamily = FontFamily.Monospace),
55+
RichMarkupFormat.Keyboard to SpanStyle(fontFamily = FontFamily.Monospace),
56+
RichMarkupFormat.Highlight to SpanStyle(background = highlightBackground),
57+
)
58+
}
59+
val annotated = remember(nodes, linkStyles, formatStyles) {
60+
buildAnnotatedString {
61+
appendMarkup(
62+
nodes = nodes,
63+
style = SpanStyle(),
64+
linkStyles = linkStyles,
65+
formatStyles = formatStyles,
66+
interactive = true,
67+
onUrlClick = latestOnUrlClick,
68+
onTimestampClick = latestOnTimestampClick,
69+
)
70+
}
71+
}
72+
Text(
73+
text = annotated,
74+
style = style,
75+
modifier = modifier,
76+
maxLines = maxLines,
77+
overflow = overflow,
78+
)
79+
}
80+
81+
private fun AnnotatedString.Builder.appendMarkup(
82+
nodes: List<RichMarkupNode>,
83+
style: SpanStyle,
84+
linkStyles: TextLinkStyles,
85+
formatStyles: Map<RichMarkupFormat, SpanStyle>,
86+
interactive: Boolean,
87+
onUrlClick: State<(String) -> Unit>,
88+
onTimestampClick: State<(Long) -> Unit>,
89+
) {
90+
nodes.forEach { node ->
91+
when (node) {
92+
is RichMarkupNode.Text -> appendText(
93+
value = node.value,
94+
style = style,
95+
linkStyles = linkStyles,
96+
interactive = interactive,
97+
onUrlClick = onUrlClick,
98+
onTimestampClick = onTimestampClick,
99+
)
100+
is RichMarkupNode.Break -> append('\n')
101+
is RichMarkupNode.Link -> withLink(
102+
LinkAnnotation.Url(
103+
url = node.href,
104+
styles = linkStyles,
105+
linkInteractionListener = { onUrlClick.value(node.href) },
106+
),
107+
) {
108+
appendMarkup(
109+
nodes = node.children,
110+
style = style,
111+
linkStyles = linkStyles,
112+
formatStyles = formatStyles,
113+
interactive = false,
114+
onUrlClick = onUrlClick,
115+
onTimestampClick = onTimestampClick,
116+
)
117+
}
118+
is RichMarkupNode.Format -> appendMarkup(
119+
nodes = node.children,
120+
style = style.merge(formatStyles.getValue(node.format)),
121+
linkStyles = linkStyles,
122+
formatStyles = formatStyles,
123+
interactive = interactive,
124+
onUrlClick = onUrlClick,
125+
onTimestampClick = onTimestampClick,
126+
)
127+
}
128+
}
129+
}
130+
131+
private fun AnnotatedString.Builder.appendText(
132+
value: String,
133+
style: SpanStyle,
134+
linkStyles: TextLinkStyles,
135+
interactive: Boolean,
136+
onUrlClick: State<(String) -> Unit>,
137+
onTimestampClick: State<(Long) -> Unit>,
138+
) {
139+
if (!interactive) {
140+
withStyle(style) { append(value) }
141+
return
142+
}
143+
withStyle(style) {
144+
var cursor = 0
145+
interactiveTextRanges(value).forEach { range ->
146+
append(value.substring(cursor, range.start))
147+
when (range) {
148+
is InteractiveTextRange.Url -> withLink(
149+
LinkAnnotation.Url(
150+
url = range.value,
151+
styles = linkStyles,
152+
linkInteractionListener = { onUrlClick.value(range.value) },
153+
),
154+
) {
155+
append(value.substring(range.start, range.endExclusive))
156+
}
157+
is InteractiveTextRange.Timestamp -> withLink(
158+
LinkAnnotation.Clickable(
159+
tag = range.positionMillis.toString(),
160+
styles = linkStyles,
161+
linkInteractionListener = { onTimestampClick.value(range.positionMillis) },
162+
),
163+
) {
164+
append(value.substring(range.start, range.endExclusive))
165+
}
166+
}
167+
cursor = range.endExclusive
168+
}
169+
append(value.substring(cursor))
170+
}
171+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package dev.typetype.android.feature.player.components
2+
3+
import org.jsoup.Jsoup
4+
import org.jsoup.nodes.Element
5+
import org.jsoup.nodes.Node
6+
import org.jsoup.nodes.TextNode
7+
import java.net.URI
8+
9+
internal enum class RichMarkupFormat {
10+
Strong,
11+
Emphasized,
12+
Underline,
13+
Strikethrough,
14+
Code,
15+
Keyboard,
16+
Highlight,
17+
}
18+
19+
internal sealed interface RichMarkupNode {
20+
data class Text(val value: String) : RichMarkupNode
21+
22+
data object Break : RichMarkupNode
23+
24+
data class Link(val href: String, val children: List<RichMarkupNode>) : RichMarkupNode
25+
26+
data class Format(val format: RichMarkupFormat, val children: List<RichMarkupNode>) : RichMarkupNode
27+
}
28+
29+
private val formatTags = setOf(
30+
"b", "strong", "em", "i", "u", "s", "strike", "del", "code", "kbd", "mark",
31+
)
32+
private val blockTags = setOf(
33+
"address", "article", "aside", "blockquote", "div", "li", "p", "pre",
34+
)
35+
private val omittedTags = setOf(
36+
"audio", "base", "embed", "form", "iframe", "img", "link", "meta",
37+
"object", "script", "style", "svg", "template", "video",
38+
)
39+
private val escapedMarkupPattern = Regex(
40+
pattern = """&lt;\s*(?:a|br|b|strong|em|i|u|s|p|div)\b""",
41+
options = setOf(RegexOption.IGNORE_CASE),
42+
)
43+
44+
internal fun parseRichMarkup(source: String): List<RichMarkupNode> {
45+
val document = Jsoup.parseBodyFragment(source).body()
46+
val nodes = parseChildren(document.childNodes())
47+
val markupFree = nodes.all { it is RichMarkupNode.Text }
48+
if (markupFree && escapedMarkupPattern.containsMatchIn(source)) {
49+
val decoded = document.text()
50+
if (decoded != source) {
51+
return parseChildren(Jsoup.parseBodyFragment(decoded).body().childNodes())
52+
}
53+
}
54+
return nodes
55+
}
56+
57+
internal fun richMarkupPlainText(source: String): String {
58+
val builder = StringBuilder()
59+
fun visit(nodes: List<RichMarkupNode>) {
60+
nodes.forEach { node ->
61+
when (node) {
62+
is RichMarkupNode.Text -> builder.append(node.value)
63+
is RichMarkupNode.Break -> builder.append('\n')
64+
is RichMarkupNode.Link -> visit(node.children)
65+
is RichMarkupNode.Format -> visit(node.children)
66+
}
67+
}
68+
}
69+
visit(parseRichMarkup(source))
70+
return builder.toString()
71+
}
72+
73+
private fun parseChildren(nodes: List<Node>): List<RichMarkupNode> {
74+
val result = mutableListOf<RichMarkupNode>()
75+
nodes.forEach { node ->
76+
when (node) {
77+
is TextNode -> if (node.wholeText.isNotEmpty()) {
78+
result += RichMarkupNode.Text(node.wholeText)
79+
}
80+
is Element -> parseElement(node, result)
81+
else -> Unit
82+
}
83+
}
84+
return result
85+
}
86+
87+
private fun parseElement(element: Element, result: MutableList<RichMarkupNode>) {
88+
val tag = element.tagName().lowercase()
89+
if (tag in omittedTags) return
90+
if (tag == "br") {
91+
appendBreak(result)
92+
return
93+
}
94+
val children = parseChildren(element.childNodes())
95+
if (tag == "a") {
96+
val href = element.attr("href")
97+
if (isSafeHttpUrl(href)) {
98+
result += RichMarkupNode.Link(href, children)
99+
} else {
100+
result += children
101+
}
102+
return
103+
}
104+
formatForTag(tag)?.let { format ->
105+
result += RichMarkupNode.Format(format, children)
106+
return
107+
}
108+
if (tag in blockTags) {
109+
appendBreak(result)
110+
result += children
111+
appendBreak(result)
112+
return
113+
}
114+
result += children
115+
}
116+
117+
private fun formatForTag(tag: String): RichMarkupFormat? = when (tag) {
118+
"b", "strong" -> RichMarkupFormat.Strong
119+
"em", "i" -> RichMarkupFormat.Emphasized
120+
"u" -> RichMarkupFormat.Underline
121+
"s", "strike", "del" -> RichMarkupFormat.Strikethrough
122+
"code" -> RichMarkupFormat.Code
123+
"kbd" -> RichMarkupFormat.Keyboard
124+
"mark" -> RichMarkupFormat.Highlight
125+
else -> null
126+
}
127+
128+
private fun appendBreak(nodes: MutableList<RichMarkupNode>) {
129+
if (nodes.isNotEmpty() && nodes.last() != RichMarkupNode.Break) {
130+
nodes += RichMarkupNode.Break
131+
}
132+
}
133+
134+
private fun isSafeHttpUrl(value: String): Boolean = runCatching {
135+
val scheme = URI(value).scheme ?: return@runCatching false
136+
scheme.equals("http", ignoreCase = true) || scheme.equals("https", ignoreCase = true)
137+
}.getOrDefault(false)

0 commit comments

Comments
 (0)