diff --git a/vector/build.gradle b/vector/build.gradle index 34c01c028e..d639b4c3e8 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -219,7 +219,7 @@ dependencies { def epoxy_version = '3.8.0' def arrow_version = "0.8.2" def coroutines_version = "1.3.2" - def markwon_version = '3.1.0' + def markwon_version = '4.1.2' def big_image_viewer_version = '1.5.6' def glide_version = '4.10.0' def moshi_version = '1.8.0' @@ -283,8 +283,8 @@ dependencies { implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation 'com.google.android.material:material:1.1.0-beta01' implementation 'me.gujun.android:span:1.7' - implementation "ru.noties.markwon:core:$markwon_version" - implementation "ru.noties.markwon:html:$markwon_version" + implementation "io.noties.markwon:core:$markwon_version" + implementation "io.noties.markwon:html:$markwon_version" implementation 'me.saket:better-link-movement-method:2.2.0' implementation 'com.google.android:flexbox:1.1.1' diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 0bb5c3a1d8..cc00ba9fb6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -46,9 +46,11 @@ import im.vector.riotx.features.home.room.detail.timeline.TimelineEventControlle import im.vector.riotx.features.home.room.detail.timeline.helper.* import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.html.EventHtmlRenderer +import im.vector.riotx.features.html.CodeVisitor import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer import me.gujun.android.span.span +import org.commonmark.node.Document import javax.inject.Inject class MessageItemFactory @Inject constructor( @@ -234,22 +236,33 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageTextItem? { + val isFormatted = messageContent.formattedBody.isNullOrBlank().not() - val bodyToUse = messageContent.formattedBody?.let { - htmlRenderer.get().render(it.trim()) - } ?: messageContent.body + val bodyToUse = if (isFormatted) { + val formattedBody = htmlRenderer.get().parse(messageContent.body) as Document + val codeVisitor = CodeVisitor() + codeVisitor.visit(formattedBody) + if (codeVisitor.codeKind == CodeVisitor.Kind.NONE) { + messageContent.formattedBody.let { + htmlRenderer.get().render(it!!.trim()) + } + } else { + htmlRenderer.get().render(formattedBody) + } + } else { + messageContent.body + } val linkifiedBody = linkifyBody(bodyToUse, callback) - return MessageTextItem_() - .apply { - if (informationData.hasBeenEdited) { - val spannable = annotateWithEdited(linkifiedBody, callback, informationData) - message(spannable) - } else { - message(linkifiedBody) - } - } + return MessageTextItem_().apply { + if (informationData.hasBeenEdited) { + val spannable = annotateWithEdited(linkifiedBody, callback, informationData) + message(spannable) + } else { + message(linkifiedBody) + } + } .useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString())) .searchForPills(isFormatted) .leftGuideline(avatarSizeProvider.leftGuideline) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageBlockCodeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageBlockCodeItem.kt new file mode 100644 index 0000000000..1ac35181ce --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageBlockCodeItem.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.home.room.detail.timeline.item + +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base) +abstract class MessageBlockCodeItem : AbsMessageItem() { + + @EpoxyAttribute + var message: CharSequence? = null + + override fun bind(holder: Holder) { + super.bind(holder) + + } + + override fun getViewType() = STUB_ID + + class Holder : AbsMessageItem.Holder(STUB_ID) { + + } + + companion object { + private const val STUB_ID = R.id.messageContentCodeBlockStub + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/html/CodeVisitor.kt b/vector/src/main/java/im/vector/riotx/features/html/CodeVisitor.kt new file mode 100644 index 0000000000..009d74818a --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/html/CodeVisitor.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.html + +import org.commonmark.node.AbstractVisitor +import org.commonmark.node.Code +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.IndentedCodeBlock + +/** + * This class is in charge of visiting nodes and tells if we have some code nodes (inline or block). + */ +class CodeVisitor : AbstractVisitor() { + + var codeKind: Kind = Kind.NONE + private set + + override fun visit(fencedCodeBlock: FencedCodeBlock?) { + if (codeKind == Kind.NONE) { + codeKind = Kind.BLOCK + } + } + + override fun visit(indentedCodeBlock: IndentedCodeBlock?) { + if (codeKind == Kind.NONE) { + codeKind = Kind.BLOCK + } + } + + override fun visit(code: Code?) { + if (codeKind == Kind.NONE) { + codeKind = Kind.INLINE + } + } + + enum class Kind { + NONE, + INLINE, + BLOCK + } + +} diff --git a/vector/src/main/java/im/vector/riotx/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/riotx/features/html/EventHtmlRenderer.kt index 06af8ebca5..4e387a5b12 100644 --- a/vector/src/main/java/im/vector/riotx/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/riotx/features/html/EventHtmlRenderer.kt @@ -17,171 +17,47 @@ package im.vector.riotx.features.html import android.content.Context -import android.text.style.URLSpan -import im.vector.matrix.android.api.permalinks.PermalinkData -import im.vector.matrix.android.api.permalinks.PermalinkParser import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.glide.GlideApp -import im.vector.riotx.core.glide.GlideRequests import im.vector.riotx.features.home.AvatarRenderer -import org.commonmark.node.BlockQuote -import org.commonmark.node.HtmlBlock -import org.commonmark.node.HtmlInline +import io.noties.markwon.Markwon +import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.html.TagHandlerNoOp import org.commonmark.node.Node -import ru.noties.markwon.* -import ru.noties.markwon.html.HtmlTag -import ru.noties.markwon.html.MarkwonHtmlParserImpl -import ru.noties.markwon.html.MarkwonHtmlRenderer -import ru.noties.markwon.html.TagHandler -import ru.noties.markwon.html.tag.* -import java.util.Arrays.asList import javax.inject.Inject import javax.inject.Singleton @Singleton class EventHtmlRenderer @Inject constructor(context: Context, - avatarRenderer: AvatarRenderer, - sessionHolder: ActiveSessionHolder) { + htmlConfigure: MatrixHtmlPluginConfigure) { + private val markwon = Markwon.builder(context) - .usePlugin(MatrixPlugin.create(GlideApp.with(context), context, avatarRenderer, sessionHolder)) + .usePlugin(HtmlPlugin.create(htmlConfigure)) .build() + fun parse(text: String): Node { + return markwon.parse(text) + } + fun render(text: String): CharSequence { return markwon.toMarkdown(text) } - fun render(node: Node) : CharSequence { + fun render(node: Node): CharSequence { return markwon.render(node) } } -private class MatrixPlugin private constructor(private val glideRequests: GlideRequests, - private val context: Context, - private val avatarRenderer: AvatarRenderer, - private val session: ActiveSessionHolder) : AbstractMarkwonPlugin() { +class MatrixHtmlPluginConfigure @Inject constructor(private val context: Context, + private val avatarRenderer: AvatarRenderer, + private val session: ActiveSessionHolder) : HtmlPlugin.HtmlConfigure { - override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { - builder.htmlParser(MarkwonHtmlParserImpl.create()) + override fun configureHtml(plugin: HtmlPlugin) { + plugin + .addHandler(TagHandlerNoOp.create("a")) + .addHandler(FontTagHandler()) + .addHandler(MxLinkTagHandler(GlideApp.with(context), context, avatarRenderer, session)) + .addHandler(MxReplyTagHandler()) } - override fun configureHtmlRenderer(builder: MarkwonHtmlRenderer.Builder) { - builder - .setHandler( - "img", - ImageHandler.create()) - .setHandler( - "a", - MxLinkHandler(glideRequests, context, avatarRenderer, session)) - .setHandler( - "blockquote", - BlockquoteHandler()) - .setHandler( - "font", - FontTagHandler()) - .setHandler( - "sub", - SubScriptHandler()) - .setHandler( - "sup", - SuperScriptHandler()) - .setHandler( - asList("b", "strong"), - StrongEmphasisHandler()) - .setHandler( - asList("s", "del"), - StrikeHandler()) - .setHandler( - asList("u", "ins"), - UnderlineHandler()) - .setHandler( - asList("ul", "ol"), - ListHandler()) - .setHandler( - asList("i", "em", "cite", "dfn"), - EmphasisHandler()) - .setHandler( - asList("h1", "h2", "h3", "h4", "h5", "h6"), - HeadingHandler()) - .setHandler("mx-reply", - MxReplyTagHandler()) - } - - override fun afterRender(node: Node, visitor: MarkwonVisitor) { - val configuration = visitor.configuration() - configuration.htmlRenderer().render(visitor, configuration.htmlParser()) - } - - override fun configureVisitor(builder: MarkwonVisitor.Builder) { - builder - .on(HtmlBlock::class.java) { visitor, htmlBlock -> visitHtml(visitor, htmlBlock.literal) } - .on(HtmlInline::class.java) { visitor, htmlInline -> visitHtml(visitor, htmlInline.literal) } - } - - private fun visitHtml(visitor: MarkwonVisitor, html: String?) { - if (html != null) { - visitor.configuration().htmlParser().processFragment(visitor.builder(), html) - } - } - - companion object { - - fun create(glideRequests: GlideRequests, context: Context, avatarRenderer: AvatarRenderer, session: ActiveSessionHolder): MatrixPlugin { - return MatrixPlugin(glideRequests, context, avatarRenderer, session) - } - } -} - -private class MxLinkHandler(private val glideRequests: GlideRequests, - private val context: Context, - private val avatarRenderer: AvatarRenderer, - private val sessionHolder: ActiveSessionHolder) : TagHandler() { - - private val linkHandler = LinkHandler() - - override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { - val link = tag.attributes()["href"] - if (link != null) { - val permalinkData = PermalinkParser.parse(link) - when (permalinkData) { - is PermalinkData.UserLink -> { - val user = sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId) - val span = PillImageSpan(glideRequests, avatarRenderer, context, permalinkData.userId, user) - SpannableBuilder.setSpans( - visitor.builder(), - span, - tag.start(), - tag.end() - ) - // also add clickable span - SpannableBuilder.setSpans( - visitor.builder(), - URLSpan(link), - tag.start(), - tag.end() - ) - } - else -> linkHandler.handle(visitor, renderer, tag) - } - } else { - linkHandler.handle(visitor, renderer, tag) - } - } -} - -private class MxReplyTagHandler : TagHandler() { - - override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { - val configuration = visitor.configuration() - val factory = configuration.spansFactory().get(BlockQuote::class.java) - if (factory != null) { - SpannableBuilder.setSpans( - visitor.builder(), - factory.getSpans(configuration, visitor.renderProps()), - tag.start(), - tag.end() - ) - val replyText = visitor.builder().removeFromEnd(tag.end()) - visitor.builder().append("\n\n").append(replyText) - } - } } diff --git a/vector/src/main/java/im/vector/riotx/features/html/FontTagHandler.kt b/vector/src/main/java/im/vector/riotx/features/html/FontTagHandler.kt index f4fa1737c9..e5733dd849 100644 --- a/vector/src/main/java/im/vector/riotx/features/html/FontTagHandler.kt +++ b/vector/src/main/java/im/vector/riotx/features/html/FontTagHandler.kt @@ -17,15 +17,18 @@ package im.vector.riotx.features.html import android.graphics.Color import android.text.style.ForegroundColorSpan -import ru.noties.markwon.MarkwonConfiguration -import ru.noties.markwon.RenderProps -import ru.noties.markwon.html.HtmlTag -import ru.noties.markwon.html.tag.SimpleTagHandler +import io.noties.markwon.MarkwonConfiguration +import io.noties.markwon.RenderProps +import io.noties.markwon.html.HtmlTag +import io.noties.markwon.html.tag.SimpleTagHandler /** * custom to matrix for IRC-style font coloring */ class FontTagHandler : SimpleTagHandler() { + + override fun supportedTags() = listOf("font") + override fun getSpans(configuration: MarkwonConfiguration, renderProps: RenderProps, tag: HtmlTag): Any? { val colorString = tag.attributes()["color"]?.let { parseColor(it) } ?: Color.BLACK return ForegroundColorSpan(colorString) @@ -37,23 +40,23 @@ class FontTagHandler : SimpleTagHandler() { } catch (e: Exception) { // try other w3c colors? return when (color_name) { - "white" -> Color.WHITE - "yellow" -> Color.YELLOW + "white" -> Color.WHITE + "yellow" -> Color.YELLOW "fuchsia" -> Color.parseColor("#FF00FF") - "red" -> Color.RED - "silver" -> Color.parseColor("#C0C0C0") - "gray" -> Color.GRAY - "olive" -> Color.parseColor("#808000") - "purple" -> Color.parseColor("#800080") - "maroon" -> Color.parseColor("#800000") - "aqua" -> Color.parseColor("#00FFFF") - "lime" -> Color.parseColor("#00FF00") - "teal" -> Color.parseColor("#008080") - "green" -> Color.GREEN - "blue" -> Color.BLUE - "orange" -> Color.parseColor("#FFA500") - "navy" -> Color.parseColor("#000080") - else -> Color.BLACK + "red" -> Color.RED + "silver" -> Color.parseColor("#C0C0C0") + "gray" -> Color.GRAY + "olive" -> Color.parseColor("#808000") + "purple" -> Color.parseColor("#800080") + "maroon" -> Color.parseColor("#800000") + "aqua" -> Color.parseColor("#00FFFF") + "lime" -> Color.parseColor("#00FF00") + "teal" -> Color.parseColor("#008080") + "green" -> Color.GREEN + "blue" -> Color.BLUE + "orange" -> Color.parseColor("#FFA500") + "navy" -> Color.parseColor("#000080") + else -> Color.BLACK } } } diff --git a/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt new file mode 100644 index 0000000000..fdcbb12cd7 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/html/MxLinkTagHandler.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.html + +import android.content.Context +import android.text.style.URLSpan +import im.vector.matrix.android.api.permalinks.PermalinkData +import im.vector.matrix.android.api.permalinks.PermalinkParser +import im.vector.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.glide.GlideRequests +import im.vector.riotx.features.home.AvatarRenderer +import io.noties.markwon.MarkwonVisitor +import io.noties.markwon.SpannableBuilder +import io.noties.markwon.html.HtmlTag +import io.noties.markwon.html.MarkwonHtmlRenderer +import io.noties.markwon.html.tag.LinkHandler + +class MxLinkTagHandler(private val glideRequests: GlideRequests, + private val context: Context, + private val avatarRenderer: AvatarRenderer, + private val sessionHolder: ActiveSessionHolder) : LinkHandler() { + + override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { + val link = tag.attributes()["href"] + if (link != null) { + val permalinkData = PermalinkParser.parse(link) + when (permalinkData) { + is PermalinkData.UserLink -> { + val user = sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId) + val span = PillImageSpan(glideRequests, avatarRenderer, context, permalinkData.userId, user) + SpannableBuilder.setSpans( + visitor.builder(), + span, + tag.start(), + tag.end() + ) + // also add clickable span + SpannableBuilder.setSpans( + visitor.builder(), + URLSpan(link), + tag.start(), + tag.end() + ) + } + else -> super.handle(visitor, renderer, tag) + } + } else { + super.handle(visitor, renderer, tag) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/html/MxReplyTagHandler.kt b/vector/src/main/java/im/vector/riotx/features/html/MxReplyTagHandler.kt new file mode 100644 index 0000000000..f999e253c7 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/html/MxReplyTagHandler.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2019 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.riotx.features.html + +import io.noties.markwon.MarkwonVisitor +import io.noties.markwon.SpannableBuilder +import io.noties.markwon.html.HtmlTag +import io.noties.markwon.html.MarkwonHtmlRenderer +import io.noties.markwon.html.TagHandler +import org.commonmark.node.BlockQuote + +class MxReplyTagHandler : TagHandler() { + + override fun supportedTags() = listOf("mx-reply") + + override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) { + val configuration = visitor.configuration() + val factory = configuration.spansFactory().get(BlockQuote::class.java) + if (factory != null) { + SpannableBuilder.setSpans( + visitor.builder(), + factory.getSpans(configuration, visitor.renderProps()), + tag.start(), + tag.end() + ) + val replyText = visitor.builder().removeFromEnd(tag.end()) + visitor.builder().append("\n\n").append(replyText) + } + } +} diff --git a/vector/src/main/res/layout/item_timeline_event_base.xml b/vector/src/main/res/layout/item_timeline_event_base.xml index fbe3b70551..260c309f6f 100644 --- a/vector/src/main/res/layout/item_timeline_event_base.xml +++ b/vector/src/main/res/layout/item_timeline_event_base.xml @@ -78,6 +78,14 @@ android:layout="@layout/item_timeline_event_text_message_stub" tools:visibility="visible" /> + + + + + + + + + + + + + + + +