Timeline: Start handling code blocks. [WIP]

This commit is contained in:
ganfra 2019-10-29 19:08:48 +01:00
parent 118870bc41
commit 3517873156
10 changed files with 328 additions and 179 deletions

View file

@ -219,7 +219,7 @@ dependencies {
def epoxy_version = '3.8.0' def epoxy_version = '3.8.0'
def arrow_version = "0.8.2" def arrow_version = "0.8.2"
def coroutines_version = "1.3.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 big_image_viewer_version = '1.5.6'
def glide_version = '4.10.0' def glide_version = '4.10.0'
def moshi_version = '1.8.0' def moshi_version = '1.8.0'
@ -283,8 +283,8 @@ dependencies {
implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1' implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1'
implementation 'com.google.android.material:material:1.1.0-beta01' implementation 'com.google.android.material:material:1.1.0-beta01'
implementation 'me.gujun.android:span:1.7' implementation 'me.gujun.android:span:1.7'
implementation "ru.noties.markwon:core:$markwon_version" implementation "io.noties.markwon:core:$markwon_version"
implementation "ru.noties.markwon:html:$markwon_version" implementation "io.noties.markwon:html:$markwon_version"
implementation 'me.saket:better-link-movement-method:2.2.0' implementation 'me.saket:better-link-movement-method:2.2.0'
implementation 'com.google.android:flexbox:1.1.1' implementation 'com.google.android:flexbox:1.1.1'

View file

@ -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.helper.*
import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.home.room.detail.timeline.item.*
import im.vector.riotx.features.html.EventHtmlRenderer 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.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer import im.vector.riotx.features.media.VideoContentRenderer
import me.gujun.android.span.span import me.gujun.android.span.span
import org.commonmark.node.Document
import javax.inject.Inject import javax.inject.Inject
class MessageItemFactory @Inject constructor( class MessageItemFactory @Inject constructor(
@ -234,15 +236,26 @@ class MessageItemFactory @Inject constructor(
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?, callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageTextItem? { attributes: AbsMessageItem.Attributes): MessageTextItem? {
val isFormatted = messageContent.formattedBody.isNullOrBlank().not() val isFormatted = messageContent.formattedBody.isNullOrBlank().not()
val bodyToUse = messageContent.formattedBody?.let { val bodyToUse = if (isFormatted) {
htmlRenderer.get().render(it.trim()) val formattedBody = htmlRenderer.get().parse(messageContent.body) as Document
} ?: messageContent.body 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) val linkifiedBody = linkifyBody(bodyToUse, callback)
return MessageTextItem_() return MessageTextItem_().apply {
.apply {
if (informationData.hasBeenEdited) { if (informationData.hasBeenEdited) {
val spannable = annotateWithEdited(linkifiedBody, callback, informationData) val spannable = annotateWithEdited(linkifiedBody, callback, informationData)
message(spannable) message(spannable)

View file

@ -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<MessageBlockCodeItem.Holder>() {
@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
}
}

View file

@ -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
}
}

View file

@ -17,35 +17,28 @@
package im.vector.riotx.features.html package im.vector.riotx.features.html
import android.content.Context 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.di.ActiveSessionHolder
import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.glide.GlideRequests
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import org.commonmark.node.BlockQuote import io.noties.markwon.Markwon
import org.commonmark.node.HtmlBlock import io.noties.markwon.html.HtmlPlugin
import org.commonmark.node.HtmlInline import io.noties.markwon.html.TagHandlerNoOp
import org.commonmark.node.Node 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class EventHtmlRenderer @Inject constructor(context: Context, class EventHtmlRenderer @Inject constructor(context: Context,
avatarRenderer: AvatarRenderer, htmlConfigure: MatrixHtmlPluginConfigure) {
sessionHolder: ActiveSessionHolder) {
private val markwon = Markwon.builder(context) private val markwon = Markwon.builder(context)
.usePlugin(MatrixPlugin.create(GlideApp.with(context), context, avatarRenderer, sessionHolder)) .usePlugin(HtmlPlugin.create(htmlConfigure))
.build() .build()
fun parse(text: String): Node {
return markwon.parse(text)
}
fun render(text: String): CharSequence { fun render(text: String): CharSequence {
return markwon.toMarkdown(text) return markwon.toMarkdown(text)
} }
@ -55,133 +48,16 @@ class EventHtmlRenderer @Inject constructor(context: Context,
} }
} }
private class MatrixPlugin private constructor(private val glideRequests: GlideRequests, class MatrixHtmlPluginConfigure @Inject constructor(private val context: Context,
private val context: Context,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val session: ActiveSessionHolder) : AbstractMarkwonPlugin() { private val session: ActiveSessionHolder) : HtmlPlugin.HtmlConfigure {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { override fun configureHtml(plugin: HtmlPlugin) {
builder.htmlParser(MarkwonHtmlParserImpl.create()) 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<String>("b", "strong"),
StrongEmphasisHandler())
.setHandler(
asList<String>("s", "del"),
StrikeHandler())
.setHandler(
asList<String>("u", "ins"),
UnderlineHandler())
.setHandler(
asList<String>("ul", "ol"),
ListHandler())
.setHandler(
asList<String>("i", "em", "cite", "dfn"),
EmphasisHandler())
.setHandler(
asList<String>("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)
}
}
} }

View file

@ -17,15 +17,18 @@ package im.vector.riotx.features.html
import android.graphics.Color import android.graphics.Color
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import ru.noties.markwon.MarkwonConfiguration import io.noties.markwon.MarkwonConfiguration
import ru.noties.markwon.RenderProps import io.noties.markwon.RenderProps
import ru.noties.markwon.html.HtmlTag import io.noties.markwon.html.HtmlTag
import ru.noties.markwon.html.tag.SimpleTagHandler import io.noties.markwon.html.tag.SimpleTagHandler
/** /**
* custom to matrix for IRC-style font coloring * custom to matrix for IRC-style font coloring
*/ */
class FontTagHandler : SimpleTagHandler() { class FontTagHandler : SimpleTagHandler() {
override fun supportedTags() = listOf("font")
override fun getSpans(configuration: MarkwonConfiguration, renderProps: RenderProps, tag: HtmlTag): Any? { override fun getSpans(configuration: MarkwonConfiguration, renderProps: RenderProps, tag: HtmlTag): Any? {
val colorString = tag.attributes()["color"]?.let { parseColor(it) } ?: Color.BLACK val colorString = tag.attributes()["color"]?.let { parseColor(it) } ?: Color.BLACK
return ForegroundColorSpan(colorString) return ForegroundColorSpan(colorString)

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -78,6 +78,14 @@
android:layout="@layout/item_timeline_event_text_message_stub" android:layout="@layout/item_timeline_event_text_message_stub"
tools:visibility="visible" /> tools:visibility="visible" />
<ViewStub
android:id="@+id/messageContentCodeBlockStub"
style="@style/TimelineContentStubBaseParams"
android:layout_height="wrap_content"
android:inflatedId="@id/messageTextView"
android:layout="@layout/item_timeline_event_code_block_stub"
tools:visibility="visible" />
<ViewStub <ViewStub
android:id="@+id/messageContentMediaStub" android:id="@+id/messageContentMediaStub"
style="@style/TimelineContentStubBaseParams" style="@style/TimelineContentStubBaseParams"

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/messagesAdapter_body_hsv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxHeight="500dp">
<androidx.core.widget.NestedScrollView
android:id="@+id/codeBlockScrollView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fadeScrollbars="false"
android:scrollbars="vertical|horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/codeBlockTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:autoLink="none"
android:fontFamily="monospace"
android:textSize="14sp"
tools:text="" />
</HorizontalScrollView>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>