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
5 changes: 3 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ android {
applicationId = "dev.typetype.android"
minSdk = 23
targetSdk = 37
versionCode = 10809
versionName = "1.8.0-beta.9"
versionCode = 10810
versionName = "1.8.0-beta.10"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "TypeType")
}
Expand Down Expand Up @@ -140,6 +140,7 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.browser)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.jsoup)
implementation(libs.androidx.navigation.compose)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,12 @@ private fun ExpandableCommentText(
onTimestampClick: (Long) -> Unit,
) {
var expanded by remember(comment.text) { mutableStateOf(false) }
val needsTruncation = comment.text.length > COMMENT_COLLAPSE_CHARACTER_LIMIT ||
comment.text.count { it == '\n' } >= COMMENT_COLLAPSE_LINE_LIMIT
val renderedText = remember(comment.text) { richMarkupPlainText(comment.text) }
val needsTruncation = renderedText.length > COMMENT_COLLAPSE_CHARACTER_LIMIT ||
renderedText.count { it == '\n' } >= COMMENT_COLLAPSE_LINE_LIMIT

Column {
LinkedText(
CommentRichText(
text = comment.text,
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface),
linkColor = MaterialTheme.colorScheme.primary,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package dev.typetype.android.feature.player.components

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle

@Composable
internal fun CommentRichText(
text: String,
style: TextStyle,
linkColor: Color,
onUrlClick: (String) -> Unit,
onTimestampClick: (Long) -> Unit,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
) {
val latestOnUrlClick = rememberUpdatedState(onUrlClick)
val latestOnTimestampClick = rememberUpdatedState(onTimestampClick)
val nodes = remember(text) { parseRichMarkup(text) }
val linkStyles = remember(linkColor) {
TextLinkStyles(
style = SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
),
)
}
val highlightBackground = MaterialTheme.colorScheme.surfaceVariant
val formatStyles = remember(highlightBackground) {
mapOf(
RichMarkupFormat.Strong to SpanStyle(fontWeight = FontWeight.Bold),
RichMarkupFormat.Emphasized to SpanStyle(fontStyle = FontStyle.Italic),
RichMarkupFormat.Underline to SpanStyle(textDecoration = TextDecoration.Underline),
RichMarkupFormat.Strikethrough to SpanStyle(textDecoration = TextDecoration.LineThrough),
RichMarkupFormat.Code to SpanStyle(fontFamily = FontFamily.Monospace),
RichMarkupFormat.Keyboard to SpanStyle(fontFamily = FontFamily.Monospace),
RichMarkupFormat.Highlight to SpanStyle(background = highlightBackground),
)
}
val annotated = remember(nodes, linkStyles, formatStyles) {
buildAnnotatedString {
appendMarkup(
nodes = nodes,
style = SpanStyle(),
linkStyles = linkStyles,
formatStyles = formatStyles,
interactive = true,
onUrlClick = latestOnUrlClick,
onTimestampClick = latestOnTimestampClick,
)
}
}
Text(
text = annotated,
style = style,
modifier = modifier,
maxLines = maxLines,
overflow = overflow,
)
}

private fun AnnotatedString.Builder.appendMarkup(
nodes: List<RichMarkupNode>,
style: SpanStyle,
linkStyles: TextLinkStyles,
formatStyles: Map<RichMarkupFormat, SpanStyle>,
interactive: Boolean,
onUrlClick: State<(String) -> Unit>,
onTimestampClick: State<(Long) -> Unit>,
) {
nodes.forEach { node ->
when (node) {
is RichMarkupNode.Text -> appendText(
value = node.value,
style = style,
linkStyles = linkStyles,
interactive = interactive,
onUrlClick = onUrlClick,
onTimestampClick = onTimestampClick,
)
is RichMarkupNode.Break -> append('\n')
is RichMarkupNode.Link -> withLink(
LinkAnnotation.Url(
url = node.href,
styles = linkStyles,
linkInteractionListener = { onUrlClick.value(node.href) },
),
) {
appendMarkup(
nodes = node.children,
style = style,
linkStyles = linkStyles,
formatStyles = formatStyles,
interactive = false,
onUrlClick = onUrlClick,
onTimestampClick = onTimestampClick,
)
}
is RichMarkupNode.Format -> appendMarkup(
nodes = node.children,
style = style.merge(formatStyles.getValue(node.format)),
linkStyles = linkStyles,
formatStyles = formatStyles,
interactive = interactive,
onUrlClick = onUrlClick,
onTimestampClick = onTimestampClick,
)
}
}
}

private fun AnnotatedString.Builder.appendText(
value: String,
style: SpanStyle,
linkStyles: TextLinkStyles,
interactive: Boolean,
onUrlClick: State<(String) -> Unit>,
onTimestampClick: State<(Long) -> Unit>,
) {
if (!interactive) {
withStyle(style) { append(value) }
return
}
withStyle(style) {
var cursor = 0
interactiveTextRanges(value).forEach { range ->
append(value.substring(cursor, range.start))
when (range) {
is InteractiveTextRange.Url -> withLink(
LinkAnnotation.Url(
url = range.value,
styles = linkStyles,
linkInteractionListener = { onUrlClick.value(range.value) },
),
) {
append(value.substring(range.start, range.endExclusive))
}
is InteractiveTextRange.Timestamp -> withLink(
LinkAnnotation.Clickable(
tag = range.positionMillis.toString(),
styles = linkStyles,
linkInteractionListener = { onTimestampClick.value(range.positionMillis) },
),
) {
append(value.substring(range.start, range.endExclusive))
}
}
cursor = range.endExclusive
}
append(value.substring(cursor))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package dev.typetype.android.feature.player.components

import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import java.net.URI

internal enum class RichMarkupFormat {
Strong,
Emphasized,
Underline,
Strikethrough,
Code,
Keyboard,
Highlight,
}

internal sealed interface RichMarkupNode {
data class Text(val value: String) : RichMarkupNode

data object Break : RichMarkupNode

data class Link(val href: String, val children: List<RichMarkupNode>) : RichMarkupNode

data class Format(val format: RichMarkupFormat, val children: List<RichMarkupNode>) : RichMarkupNode
}

private val formatTags = setOf(
"b", "strong", "em", "i", "u", "s", "strike", "del", "code", "kbd", "mark",
)
private val blockTags = setOf(
"address", "article", "aside", "blockquote", "div", "li", "p", "pre",
)
private val omittedTags = setOf(
"audio", "base", "embed", "form", "iframe", "img", "link", "meta",
"object", "script", "style", "svg", "template", "video",
)
private val escapedMarkupPattern = Regex(
pattern = """&lt;\s*(?:a|br|b|strong|em|i|u|s|p|div)\b""",
options = setOf(RegexOption.IGNORE_CASE),
)

internal fun parseRichMarkup(source: String): List<RichMarkupNode> {
val document = Jsoup.parseBodyFragment(source).body()
val nodes = parseChildren(document.childNodes())
val markupFree = nodes.all { it is RichMarkupNode.Text }
if (markupFree && escapedMarkupPattern.containsMatchIn(source)) {
val decoded = document.text()
if (decoded != source) {
return parseChildren(Jsoup.parseBodyFragment(decoded).body().childNodes())
}
}
return nodes
}

internal fun richMarkupPlainText(source: String): String {
val builder = StringBuilder()
fun visit(nodes: List<RichMarkupNode>) {
nodes.forEach { node ->
when (node) {
is RichMarkupNode.Text -> builder.append(node.value)
is RichMarkupNode.Break -> builder.append('\n')
is RichMarkupNode.Link -> visit(node.children)
is RichMarkupNode.Format -> visit(node.children)
}
}
}
visit(parseRichMarkup(source))
return builder.toString()
}

private fun parseChildren(nodes: List<Node>): List<RichMarkupNode> {
val result = mutableListOf<RichMarkupNode>()
nodes.forEach { node ->
when (node) {
is TextNode -> if (node.wholeText.isNotEmpty()) {
result += RichMarkupNode.Text(node.wholeText)
}
is Element -> parseElement(node, result)
else -> Unit
}
}
return result
}

private fun parseElement(element: Element, result: MutableList<RichMarkupNode>) {
val tag = element.tagName().lowercase()
if (tag in omittedTags) return
if (tag == "br") {
appendBreak(result)
return
}
val children = parseChildren(element.childNodes())
if (tag == "a") {
val href = element.attr("href")
if (isSafeHttpUrl(href)) {
result += RichMarkupNode.Link(href, children)
} else {
result += children
}
return
}
formatForTag(tag)?.let { format ->
result += RichMarkupNode.Format(format, children)
return
}
if (tag in blockTags) {
appendBreak(result)
result += children
appendBreak(result)
return
}
result += children
}

private fun formatForTag(tag: String): RichMarkupFormat? = when (tag) {
"b", "strong" -> RichMarkupFormat.Strong
"em", "i" -> RichMarkupFormat.Emphasized
"u" -> RichMarkupFormat.Underline
"s", "strike", "del" -> RichMarkupFormat.Strikethrough
"code" -> RichMarkupFormat.Code
"kbd" -> RichMarkupFormat.Keyboard
"mark" -> RichMarkupFormat.Highlight
else -> null
}

private fun appendBreak(nodes: MutableList<RichMarkupNode>) {
if (nodes.isNotEmpty() && nodes.last() != RichMarkupNode.Break) {
nodes += RichMarkupNode.Break
}
}

private fun isSafeHttpUrl(value: String): Boolean = runCatching {
val scheme = URI(value).scheme ?: return@runCatching false
scheme.equals("http", ignoreCase = true) || scheme.equals("https", ignoreCase = true)
}.getOrDefault(false)
Loading