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 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'

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.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)

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,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<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.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
}
}
}

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"
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
android:id="@+id/messageContentMediaStub"
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>