Merge pull request #654 from vector-im/feature/timeline_message_code
Feature/timeline message code
This commit is contained in:
commit
36060fe332
|
@ -5,7 +5,7 @@ Features ✨:
|
||||||
-
|
-
|
||||||
|
|
||||||
Improvements 🙌:
|
Improvements 🙌:
|
||||||
-
|
- Handle code tags (#567)
|
||||||
|
|
||||||
Other changes:
|
Other changes:
|
||||||
- Accessibility improvements to the attachment file type chooser
|
- Accessibility improvements to the attachment file type chooser
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -16,17 +16,15 @@
|
||||||
|
|
||||||
package im.vector.riotx.core.platform
|
package im.vector.riotx.core.platform
|
||||||
|
|
||||||
import android.annotation.TargetApi
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.widget.ScrollView
|
import androidx.core.widget.NestedScrollView
|
||||||
|
|
||||||
import im.vector.riotx.R
|
import im.vector.riotx.R
|
||||||
|
|
||||||
private const val DEFAULT_MAX_HEIGHT = 200
|
private const val DEFAULT_MAX_HEIGHT = 200
|
||||||
|
|
||||||
class MaxHeightScrollView : ScrollView {
|
class MaxHeightScrollView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0)
|
||||||
|
: NestedScrollView(context, attrs, defStyle) {
|
||||||
|
|
||||||
var maxHeight: Int = 0
|
var maxHeight: Int = 0
|
||||||
set(value) {
|
set(value) {
|
||||||
|
@ -34,28 +32,7 @@ class MaxHeightScrollView : ScrollView {
|
||||||
requestLayout()
|
requestLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(context: Context) : super(context) {}
|
init {
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
|
||||||
if (!isInEditMode) {
|
|
||||||
init(context, attrs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
|
|
||||||
if (!isInEditMode) {
|
|
||||||
init(context, attrs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
|
||||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
|
|
||||||
if (!isInEditMode) {
|
|
||||||
init(context, attrs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun init(context: Context, attrs: AttributeSet?) {
|
|
||||||
if (attrs != null) {
|
if (attrs != null) {
|
||||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightScrollView)
|
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightScrollView)
|
||||||
maxHeight = styledAttrs.getDimensionPixelSize(R.styleable.MaxHeightScrollView_maxHeight, DEFAULT_MAX_HEIGHT)
|
maxHeight = styledAttrs.getDimensionPixelSize(R.styleable.MaxHeightScrollView_maxHeight, DEFAULT_MAX_HEIGHT)
|
||||||
|
|
|
@ -480,7 +480,7 @@ class RoomDetailFragment :
|
||||||
jumpToReadMarkerView.render(show, readMarkerId)
|
jumpToReadMarkerView.render(show, readMarkerId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
recyclerView.setController(timelineEventController)
|
recyclerView.adapter = timelineEventController.adapter
|
||||||
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||||
|
|
|
@ -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(
|
||||||
|
@ -97,16 +99,8 @@ class MessageItemFactory @Inject constructor(
|
||||||
// val all = event.root.toContent()
|
// val all = event.root.toContent()
|
||||||
// val ev = all.toModel<Event>()
|
// val ev = all.toModel<Event>()
|
||||||
return when (messageContent) {
|
return when (messageContent) {
|
||||||
is MessageEmoteContent -> buildEmoteMessageItem(messageContent,
|
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||||
informationData,
|
is MessageTextContent -> buildItemForTextContent(messageContent, informationData, highlight, callback, attributes)
|
||||||
highlight,
|
|
||||||
callback,
|
|
||||||
attributes)
|
|
||||||
is MessageTextContent -> buildTextMessageItem(messageContent,
|
|
||||||
informationData,
|
|
||||||
highlight,
|
|
||||||
callback,
|
|
||||||
attributes)
|
|
||||||
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes)
|
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||||
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
|
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||||
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
|
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||||
|
@ -229,34 +223,75 @@ class MessageItemFactory @Inject constructor(
|
||||||
.clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
|
.clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildTextMessageItem(messageContent: MessageTextContent,
|
private fun buildItemForTextContent(messageContent: MessageTextContent,
|
||||||
|
informationData: MessageInformationData,
|
||||||
|
highlight: Boolean,
|
||||||
|
callback: TimelineEventController.Callback?,
|
||||||
|
attributes: AbsMessageItem.Attributes): VectorEpoxyModel<*>? {
|
||||||
|
val isFormatted = messageContent.formattedBody.isNullOrBlank().not()
|
||||||
|
return if (isFormatted) {
|
||||||
|
val localFormattedBody = htmlRenderer.get().parse(messageContent.body) as Document
|
||||||
|
val codeVisitor = CodeVisitor()
|
||||||
|
codeVisitor.visit(localFormattedBody)
|
||||||
|
when (codeVisitor.codeKind) {
|
||||||
|
CodeVisitor.Kind.BLOCK -> {
|
||||||
|
val codeFormattedBlock = htmlRenderer.get().render(localFormattedBody)
|
||||||
|
buildCodeBlockItem(codeFormattedBlock, informationData, highlight, callback, attributes)
|
||||||
|
}
|
||||||
|
CodeVisitor.Kind.INLINE -> {
|
||||||
|
val codeFormatted = htmlRenderer.get().render(localFormattedBody)
|
||||||
|
buildMessageTextItem(codeFormatted, false, informationData, highlight, callback, attributes)
|
||||||
|
}
|
||||||
|
CodeVisitor.Kind.NONE -> {
|
||||||
|
val formattedBody = htmlRenderer.get().render(messageContent.formattedBody!!)
|
||||||
|
buildMessageTextItem(formattedBody, true, informationData, highlight, callback, attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMessageTextItem(body: CharSequence,
|
||||||
|
isFormatted: Boolean,
|
||||||
informationData: MessageInformationData,
|
informationData: MessageInformationData,
|
||||||
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 linkifiedBody = linkifyBody(body, callback)
|
||||||
val bodyToUse = messageContent.formattedBody?.let {
|
|
||||||
htmlRenderer.get().render(it.trim())
|
|
||||||
} ?: messageContent.body
|
|
||||||
|
|
||||||
val linkifiedBody = linkifyBody(bodyToUse, callback)
|
return MessageTextItem_().apply {
|
||||||
|
if (informationData.hasBeenEdited) {
|
||||||
return MessageTextItem_()
|
val spannable = annotateWithEdited(linkifiedBody, callback, informationData)
|
||||||
.apply {
|
message(spannable)
|
||||||
if (informationData.hasBeenEdited) {
|
} else {
|
||||||
val spannable = annotateWithEdited(linkifiedBody, callback, informationData)
|
message(linkifiedBody)
|
||||||
message(spannable)
|
}
|
||||||
} else {
|
}
|
||||||
message(linkifiedBody)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString()))
|
.useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString()))
|
||||||
.searchForPills(isFormatted)
|
.searchForPills(isFormatted)
|
||||||
.leftGuideline(avatarSizeProvider.leftGuideline)
|
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||||
.attributes(attributes)
|
.attributes(attributes)
|
||||||
.highlighted(highlight)
|
.highlighted(highlight)
|
||||||
.urlClickCallback(callback)
|
.urlClickCallback(callback)
|
||||||
// click on the text
|
}
|
||||||
|
|
||||||
|
private fun buildCodeBlockItem(formattedBody: CharSequence,
|
||||||
|
informationData: MessageInformationData,
|
||||||
|
highlight: Boolean,
|
||||||
|
callback: TimelineEventController.Callback?,
|
||||||
|
attributes: AbsMessageItem.Attributes): MessageBlockCodeItem? {
|
||||||
|
return MessageBlockCodeItem_()
|
||||||
|
.apply {
|
||||||
|
if (informationData.hasBeenEdited) {
|
||||||
|
val spannable = annotateWithEdited("", callback, informationData)
|
||||||
|
editedSpan(spannable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||||
|
.attributes(attributes)
|
||||||
|
.highlighted(highlight)
|
||||||
|
.message(formattedBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun annotateWithEdited(linkifiedBody: CharSequence,
|
private fun annotateWithEdited(linkifiedBody: CharSequence,
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* 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 android.widget.TextView
|
||||||
|
import com.airbnb.epoxy.EpoxyAttribute
|
||||||
|
import com.airbnb.epoxy.EpoxyModelClass
|
||||||
|
import im.vector.riotx.R
|
||||||
|
import im.vector.riotx.core.extensions.setTextOrHide
|
||||||
|
import me.saket.bettermovementmethod.BetterLinkMovementMethod
|
||||||
|
|
||||||
|
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
|
||||||
|
abstract class MessageBlockCodeItem : AbsMessageItem<MessageBlockCodeItem.Holder>() {
|
||||||
|
|
||||||
|
@EpoxyAttribute
|
||||||
|
var message: CharSequence? = null
|
||||||
|
@EpoxyAttribute
|
||||||
|
var editedSpan: CharSequence? = null
|
||||||
|
|
||||||
|
override fun bind(holder: Holder) {
|
||||||
|
super.bind(holder)
|
||||||
|
holder.messageView.text = message
|
||||||
|
renderSendState(holder.messageView, holder.messageView)
|
||||||
|
holder.messageView.setOnClickListener(attributes.itemClickListener)
|
||||||
|
holder.messageView.setOnLongClickListener(attributes.itemLongClickListener)
|
||||||
|
holder.editedView.movementMethod = BetterLinkMovementMethod.getInstance()
|
||||||
|
holder.editedView.setTextOrHide(editedSpan)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getViewType() = STUB_ID
|
||||||
|
|
||||||
|
class Holder : AbsMessageItem.Holder(STUB_ID) {
|
||||||
|
val messageView by bind<TextView>(R.id.codeBlockTextView)
|
||||||
|
val editedView by bind<TextView>(R.id.codeBlockEditedView)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val STUB_ID = R.id.messageContentCodeBlockStub
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,171 +17,46 @@
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun render(node: Node) : CharSequence {
|
fun render(node: Node): CharSequence {
|
||||||
return markwon.render(node)
|
return markwon.render(node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) : HtmlPlugin.HtmlConfigure {
|
||||||
private val session: ActiveSessionHolder) : AbstractMarkwonPlugin() {
|
|
||||||
|
|
||||||
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
|
override fun configureHtml(plugin: HtmlPlugin) {
|
||||||
builder.htmlParser(MarkwonHtmlParserImpl.create())
|
plugin
|
||||||
}
|
.addHandler(TagHandlerNoOp.create("a"))
|
||||||
|
.addHandler(FontTagHandler())
|
||||||
override fun configureHtmlRenderer(builder: MarkwonHtmlRenderer.Builder) {
|
.addHandler(MxLinkTagHandler(GlideApp.with(context), context, avatarRenderer, session))
|
||||||
builder
|
.addHandler(MxReplyTagHandler())
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
@ -37,23 +40,23 @@ class FontTagHandler : SimpleTagHandler() {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// try other w3c colors?
|
// try other w3c colors?
|
||||||
return when (color_name) {
|
return when (color_name) {
|
||||||
"white" -> Color.WHITE
|
"white" -> Color.WHITE
|
||||||
"yellow" -> Color.YELLOW
|
"yellow" -> Color.YELLOW
|
||||||
"fuchsia" -> Color.parseColor("#FF00FF")
|
"fuchsia" -> Color.parseColor("#FF00FF")
|
||||||
"red" -> Color.RED
|
"red" -> Color.RED
|
||||||
"silver" -> Color.parseColor("#C0C0C0")
|
"silver" -> Color.parseColor("#C0C0C0")
|
||||||
"gray" -> Color.GRAY
|
"gray" -> Color.GRAY
|
||||||
"olive" -> Color.parseColor("#808000")
|
"olive" -> Color.parseColor("#808000")
|
||||||
"purple" -> Color.parseColor("#800080")
|
"purple" -> Color.parseColor("#800080")
|
||||||
"maroon" -> Color.parseColor("#800000")
|
"maroon" -> Color.parseColor("#800000")
|
||||||
"aqua" -> Color.parseColor("#00FFFF")
|
"aqua" -> Color.parseColor("#00FFFF")
|
||||||
"lime" -> Color.parseColor("#00FF00")
|
"lime" -> Color.parseColor("#00FF00")
|
||||||
"teal" -> Color.parseColor("#008080")
|
"teal" -> Color.parseColor("#008080")
|
||||||
"green" -> Color.GREEN
|
"green" -> Color.GREEN
|
||||||
"blue" -> Color.BLUE
|
"blue" -> Color.BLUE
|
||||||
"orange" -> Color.parseColor("#FFA500")
|
"orange" -> Color.parseColor("#FFA500")
|
||||||
"navy" -> Color.parseColor("#000080")
|
"navy" -> Color.parseColor("#000080")
|
||||||
else -> Color.BLACK
|
else -> Color.BLACK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -86,7 +86,7 @@
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/roomToolbar" />
|
app:layout_constraintTop_toBottomOf="@id/roomToolbar" />
|
||||||
|
|
||||||
<com.airbnb.epoxy.EpoxyRecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerView"
|
android:id="@+id/recyclerView"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
|
|
|
@ -78,6 +78,13 @@
|
||||||
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: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"
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<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:fontFamily="monospace"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
</HorizontalScrollView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/codeBlockEditedView"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
Loading…
Reference in New Issue