diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt index ca402d880a69f8c8ef5682632b0a181f3eecdfec..5bf29a1ca14ee570647aca9ee9f7c374ef2ab6cc 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/BlogPostView.kt @@ -87,6 +87,11 @@ fun main() = preview { time = System.currentTimeMillis() - 999_000 ) BlogPostView(post, {}, {}) + val htmlPost = getRandomBlogPostItem( + text = "<h1>HTML post</h1><p>This is a html blog post.\n\nIt has <a href=\"https://web.archive.org\">one author</a> and no comments.</p>", + time = System.currentTimeMillis() - 750_000 + ) + BlogPostView(htmlPost, {}, {}) val commentPost = getRandomBlogCommentItem( parent = post, comment = "This is a comment on that first blog post.\n\nIt has two lines as well.", @@ -121,12 +126,16 @@ fun BlogPostView( // when https://github.com/JetBrains/compose-jb/issues/2729 is fixed Spacer(Modifier.height(8.dp)) SelectionContainer { - Text( + HtmlText( modifier = Modifier.padding(horizontal = 8.dp).fillMaxWidth(), - text = item.text ?: "", + html = item.text ?: "", maxLines = if (onItemRepeat == null) 5 else Int.MAX_VALUE, overflow = TextOverflow.Ellipsis, - ) + ) { link -> + // TODO: handle link clicks. Display dialog warning the user about opening an external app + // with the implication that it can be used to identify the user. Also, make this actually + // clickable which it is not in the SelectionContainer at the moment. + } } Spacer(Modifier.height(8.dp)) // if no preview and a comment item, show comments diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/HtmlText.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/HtmlText.kt new file mode 100644 index 0000000000000000000000000000000000000000..92454572bb4a677e45c941160d71f9c97982aab0 --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/blog/HtmlText.kt @@ -0,0 +1,189 @@ +/* + * Briar Desktop + * Copyright (C) 2023 The Briar Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package org.briarproject.briar.desktop.blog + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +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.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import org.briarproject.briar.desktop.utils.PreviewUtils.preview +import org.intellij.lang.annotations.Language +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.select.NodeVisitor + +fun main() = preview { + @Language("HTML") + val testHtml = """ +<h1>Headline</h1> +<p>some text</p> +<h2>second headline</h2> +<p> + Hello World + <b>bold</b>, <i>italic</i>, <u>underline</u>, <b><i><u>all three</u></i></b> +</p> +<p> + This paragraph<br/> + contains a <a href="https://google.com">link to Google</a> +</p> +<p> +<ol> + <li>foo</li> + <li>bar</li> +</ol> +</p>""".trimIndent() + + HtmlText(testHtml) { + println(it) + } +} + +// This file is adapted from https://github.com/jeremyrempel/yahnapp (HtmlText.kt) + +@Composable +fun HtmlText( + html: String, + modifier: Modifier = Modifier, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + handleLink: (String) -> Unit, +) { + + // Elements we support: + // "h1", "h2", "h3", "h4", "h5", "h6", + // "a", "b", "i", "u", "br", "p" + + // Elements we still need to add support for: + // "blockquote", "cite", "code", "dd", "dl", "dt", "em", + // "li", "ol", "pre", "q", "small", "span", "strike", "strong", "sub", + // "sup", "ul" + + val h1 = MaterialTheme.typography.h1.toSpanStyle() + val h2 = MaterialTheme.typography.h2.toSpanStyle() + val h3 = MaterialTheme.typography.h3.toSpanStyle() + val h4 = MaterialTheme.typography.h4.toSpanStyle() + val h5 = MaterialTheme.typography.h5.toSpanStyle() + val h6 = MaterialTheme.typography.h6.toSpanStyle() + val bold = SpanStyle(fontWeight = FontWeight.Bold) + val italic = SpanStyle(fontStyle = FontStyle.Italic) + val underline = SpanStyle(textDecoration = TextDecoration.Underline) + val link = SpanStyle(textDecoration = TextDecoration.Underline, color = MaterialTheme.colors.primaryVariant) + + val paragraph = ParagraphStyle() + + val formattedString = remember(html) { + buildAnnotatedString { + var cursorPosition = 0 + val appendAndUpdateCursor: (String) -> Unit = { + append(it) + cursorPosition += it.length + } + + val addParagraph: () -> Unit = { + pushStyle(paragraph) + if (cursorPosition > 0) { + appendAndUpdateCursor("\n") + } + } + + val addLink: (node: Element) -> Unit = { node -> + val start = cursorPosition + val end = start + node.text().length + val href = node.attr("href") + + addStringAnnotation( + tag = "link", + start = start, + end = end, + annotation = href + ) + pushStyle(link) + } + + val doc = Jsoup.parse(html) + + doc.traverse(object : NodeVisitor { + override fun head(node: Node, depth: Int) { + when (node) { + is TextNode -> { + if (node.text().isNotBlank()) { + appendAndUpdateCursor(node.text()) + } + } + + is Element -> { + when (node.tagName()) { + "h1" -> pushStyle(h1) + "h2" -> pushStyle(h2) + "h3" -> pushStyle(h3) + "h4" -> pushStyle(h4) + "h5" -> pushStyle(h5) + "h6" -> pushStyle(h6) + "b" -> pushStyle(bold) + "i" -> pushStyle(italic) + "u" -> pushStyle(underline) + "br" -> appendAndUpdateCursor("\n") + "p" -> addParagraph() + "a" -> addLink(node) + } + } + + else -> { + throw Exception("Unknown node type") + } + } + } + + override fun tail(node: Node, depth: Int) { + if (node is Element) { + when (node.tagName()) { + "h1", "h2", "h3", "h4", "h5", "h6", "b", "i", "u", "a", "p" -> pop() + } + } + } + }) + } + } + + ClickableText( + text = formattedString, + modifier = modifier, + overflow = overflow, + maxLines = maxLines, + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface), + onClick = { offset -> + formattedString + .getStringAnnotations(start = offset, end = offset) + .firstOrNull { it.tag == "link" } + ?.let { annotation -> + handleLink(annotation.item) + } + } + ) +}