mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-11-25 02:45:53 +03:00
[Rich text editor] Add inline code to rich text editor (#8011)
Also: - Fixes https://github.com/vector-im/element-android/issues/7975 - See https://github.com/noties/Markwon/issues/423
This commit is contained in:
parent
156f4f71f9
commit
00f9c362da
15 changed files with 339 additions and 110 deletions
1
changelog.d/7975.bugfix
Normal file
1
changelog.d/7975.bugfix
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Fix extra new lines added to inline code
|
1
changelog.d/8011.feature
Normal file
1
changelog.d/8011.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
[Rich text editor] Add inline code to rich text editor
|
|
@ -3502,6 +3502,7 @@
|
||||||
<string name="rich_text_editor_link">Set link</string>
|
<string name="rich_text_editor_link">Set link</string>
|
||||||
<string name="rich_text_editor_numbered_list">Toggle numbered list</string>
|
<string name="rich_text_editor_numbered_list">Toggle numbered list</string>
|
||||||
<string name="rich_text_editor_bullet_list">Toggle bullet list</string>
|
<string name="rich_text_editor_bullet_list">Toggle bullet list</string>
|
||||||
|
<string name="rich_text_editor_inline_code">Apply inline code format</string>
|
||||||
<string name="rich_text_editor_full_screen_toggle">Toggle full screen mode</string>
|
<string name="rich_text_editor_full_screen_toggle">Toggle full screen mode</string>
|
||||||
|
|
||||||
<string name="set_link_text">Text</string>
|
<string name="set_link_text">Text</string>
|
||||||
|
|
|
@ -19,9 +19,10 @@ package im.vector.app.core.utils
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.text.Layout
|
import android.text.Layout
|
||||||
import android.text.Spannable
|
import android.text.Spanned
|
||||||
import androidx.core.text.getSpans
|
import androidx.core.text.getSpans
|
||||||
import im.vector.app.features.html.HtmlCodeSpan
|
import im.vector.app.features.html.HtmlCodeSpan
|
||||||
|
import io.element.android.wysiwyg.spans.InlineCodeSpan
|
||||||
import io.mockk.justRun
|
import io.mockk.justRun
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
@ -31,9 +32,9 @@ import io.noties.markwon.core.spans.OrderedListItemSpan
|
||||||
import io.noties.markwon.core.spans.StrongEmphasisSpan
|
import io.noties.markwon.core.spans.StrongEmphasisSpan
|
||||||
import me.gujun.android.span.style.CustomTypefaceSpan
|
import me.gujun.android.span.style.CustomTypefaceSpan
|
||||||
|
|
||||||
fun Spannable.toTestSpan(): String {
|
fun Spanned.toTestSpan(): String {
|
||||||
var output = toString()
|
var output = toString()
|
||||||
readSpansWithContent().forEach {
|
readSpansWithContent().reversed().forEach {
|
||||||
val tags = it.span.readTags()
|
val tags = it.span.readTags()
|
||||||
val remappedContent = it.span.remapContent(source = this, originalContent = it.content)
|
val remappedContent = it.span.remapContent(source = this, originalContent = it.content)
|
||||||
output = output.replace(it.content, "${tags.open}$remappedContent${tags.close}")
|
output = output.replace(it.content, "${tags.open}$remappedContent${tags.close}")
|
||||||
|
@ -41,7 +42,7 @@ fun Spannable.toTestSpan(): String {
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Spannable.readSpansWithContent() = getSpans<Any>().map { span ->
|
private fun Spanned.readSpansWithContent() = getSpans<Any>().map { span ->
|
||||||
val start = getSpanStart(span)
|
val start = getSpanStart(span)
|
||||||
val end = getSpanEnd(span)
|
val end = getSpanEnd(span)
|
||||||
SpanWithContent(
|
SpanWithContent(
|
||||||
|
@ -51,12 +52,24 @@ private fun Spannable.readSpansWithContent() = getSpans<Any>().map { span ->
|
||||||
}.reversed()
|
}.reversed()
|
||||||
|
|
||||||
private fun Any.readTags(): SpanTags {
|
private fun Any.readTags(): SpanTags {
|
||||||
return when (this::class) {
|
val tagName = when (this::class) {
|
||||||
OrderedListItemSpan::class -> SpanTags("[list item]", "[/list item]")
|
OrderedListItemSpan::class -> "list item"
|
||||||
HtmlCodeSpan::class -> SpanTags("[code]", "[/code]")
|
HtmlCodeSpan::class ->
|
||||||
StrongEmphasisSpan::class -> SpanTags("[bold]", "[/bold]")
|
if ((this as HtmlCodeSpan).isBlock) "code block" else "inline code"
|
||||||
EmphasisSpan::class, CustomTypefaceSpan::class -> SpanTags("[italic]", "[/italic]")
|
StrongEmphasisSpan::class -> "bold"
|
||||||
else -> throw IllegalArgumentException("Unknown ${this::class}")
|
EmphasisSpan::class, CustomTypefaceSpan::class -> "italic"
|
||||||
|
InlineCodeSpan::class -> "inline code"
|
||||||
|
else -> if (this::class.qualifiedName!!.startsWith("android.widget")) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
throw IllegalArgumentException("Unknown ${this::class}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (tagName == null) {
|
||||||
|
SpanTags("", "")
|
||||||
|
} else {
|
||||||
|
SpanTags("[$tagName]", "[/$tagName]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,8 @@
|
||||||
|
|
||||||
package im.vector.app.features.html
|
package im.vector.app.features.html
|
||||||
|
|
||||||
import androidx.core.text.toSpannable
|
import android.widget.TextView
|
||||||
|
import androidx.core.text.toSpanned
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import im.vector.app.core.di.ActiveSessionHolder
|
import im.vector.app.core.di.ActiveSessionHolder
|
||||||
import im.vector.app.core.resources.ColorProvider
|
import im.vector.app.core.resources.ColorProvider
|
||||||
|
@ -36,16 +37,19 @@ class EventHtmlRendererTest {
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
private val fakeVectorPreferences = mockk<VectorPreferences>().also {
|
private val fakeVectorPreferences = mockk<VectorPreferences>().also {
|
||||||
every { it.latexMathsIsEnabled() } returns false
|
every { it.latexMathsIsEnabled() } returns false
|
||||||
|
every { it.isRichTextEditorEnabled() } returns false
|
||||||
}
|
}
|
||||||
private val fakeSessionHolder = mockk<ActiveSessionHolder>()
|
private val fakeSessionHolder = mockk<ActiveSessionHolder>()
|
||||||
|
|
||||||
private val renderer = EventHtmlRenderer(
|
private val renderer = EventHtmlRenderer(
|
||||||
MatrixHtmlPluginConfigure(ColorProvider(context), context.resources),
|
MatrixHtmlPluginConfigure(ColorProvider(context), context.resources, fakeVectorPreferences),
|
||||||
context,
|
context,
|
||||||
fakeVectorPreferences,
|
fakeVectorPreferences,
|
||||||
fakeSessionHolder,
|
fakeSessionHolder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val textView: TextView = TextView(context)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun takesInitialListPositionIntoAccount() {
|
fun takesInitialListPositionIntoAccount() {
|
||||||
val result = """<ol start="5"><li>first entry<li></ol>""".renderAsTestSpan()
|
val result = """<ol start="5"><li>first entry<li></ol>""".renderAsTestSpan()
|
||||||
|
@ -57,7 +61,7 @@ class EventHtmlRendererTest {
|
||||||
fun doesNotProcessMarkdownWithinCodeBlocks() {
|
fun doesNotProcessMarkdownWithinCodeBlocks() {
|
||||||
val result = """<code>__italic__ **bold**</code>""".renderAsTestSpan()
|
val result = """<code>__italic__ **bold**</code>""".renderAsTestSpan()
|
||||||
|
|
||||||
result shouldBeEqualTo "[code]__italic__ **bold**[/code]"
|
result shouldBeEqualTo "[inline code]__italic__ **bold**[/inline code]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -71,7 +75,15 @@ class EventHtmlRendererTest {
|
||||||
fun processesHtmlWithinCodeBlocks() {
|
fun processesHtmlWithinCodeBlocks() {
|
||||||
val result = """<code><i>italic</i> <b>bold</b></code>""".renderAsTestSpan()
|
val result = """<code><i>italic</i> <b>bold</b></code>""".renderAsTestSpan()
|
||||||
|
|
||||||
result shouldBeEqualTo "[code][italic]italic[/italic] [bold]bold[/bold][/code]"
|
result shouldBeEqualTo "[inline code][italic]italic[/italic] [bold]bold[/bold][/inline code]"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processesHtmlWithinCodeBlocks_givenRichTextEditorEnabled() {
|
||||||
|
every { fakeVectorPreferences.isRichTextEditorEnabled() } returns true
|
||||||
|
val result = """<code><i>italic</i> <b>bold</b></code>""".renderAsTestSpan()
|
||||||
|
|
||||||
|
result shouldBeEqualTo "[inline code][italic]italic[/italic] [bold]bold[/bold][/inline code]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -81,5 +93,9 @@ class EventHtmlRendererTest {
|
||||||
result shouldBeEqualTo """& < > ' """"
|
result shouldBeEqualTo """& < > ' """"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.renderAsTestSpan() = renderer.render(this).toSpannable().toTestSpan()
|
private fun String.renderAsTestSpan(): String {
|
||||||
|
textView.text = renderer.render(this).toSpanned()
|
||||||
|
renderer.plugins.forEach { markwonPlugin -> markwonPlugin.afterSetText(textView) }
|
||||||
|
return textView.text.toSpanned().toTestSpan()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -246,6 +246,9 @@ internal class RichTextComposerLayout @JvmOverloads constructor(
|
||||||
addRichTextMenuItem(R.drawable.ic_composer_numbered_list, R.string.rich_text_editor_numbered_list, ComposerAction.ORDERED_LIST) {
|
addRichTextMenuItem(R.drawable.ic_composer_numbered_list, R.string.rich_text_editor_numbered_list, ComposerAction.ORDERED_LIST) {
|
||||||
views.richTextComposerEditText.toggleList(ordered = true)
|
views.richTextComposerEditText.toggleList(ordered = true)
|
||||||
}
|
}
|
||||||
|
addRichTextMenuItem(R.drawable.ic_composer_inline_code, R.string.rich_text_editor_inline_code, ComposerAction.INLINE_CODE) {
|
||||||
|
views.richTextComposerEditText.toggleInlineFormat(InlineFormat.InlineCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLink(link: String?) =
|
fun setLink(link: String?) =
|
||||||
|
|
|
@ -160,6 +160,9 @@ class MessageItemFactory @Inject constructor(
|
||||||
textRendererFactory.create(roomId)
|
textRendererFactory.create(roomId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val useRichTextEditorStyle: Boolean get() =
|
||||||
|
vectorPreferences.isRichTextEditorEnabled()
|
||||||
|
|
||||||
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
|
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
|
||||||
val event = params.event
|
val event = params.event
|
||||||
val highlight = params.isHighlighted
|
val highlight = params.isHighlighted
|
||||||
|
@ -480,6 +483,7 @@ class MessageItemFactory @Inject constructor(
|
||||||
highlight,
|
highlight,
|
||||||
callback,
|
callback,
|
||||||
attributes,
|
attributes,
|
||||||
|
useRichTextEditorStyle = vectorPreferences.isRichTextEditorEnabled(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -586,7 +590,7 @@ class MessageItemFactory @Inject constructor(
|
||||||
val replyToContent = messageContent.relatesTo?.inReplyTo
|
val replyToContent = messageContent.relatesTo?.inReplyTo
|
||||||
buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes, replyToContent)
|
buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes, replyToContent)
|
||||||
} else {
|
} else {
|
||||||
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
|
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes, useRichTextEditorStyle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -610,6 +614,7 @@ class MessageItemFactory @Inject constructor(
|
||||||
highlight,
|
highlight,
|
||||||
callback,
|
callback,
|
||||||
attributes,
|
attributes,
|
||||||
|
useRichTextEditorStyle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -620,6 +625,7 @@ class MessageItemFactory @Inject constructor(
|
||||||
highlight: Boolean,
|
highlight: Boolean,
|
||||||
callback: TimelineEventController.Callback?,
|
callback: TimelineEventController.Callback?,
|
||||||
attributes: AbsMessageItem.Attributes,
|
attributes: AbsMessageItem.Attributes,
|
||||||
|
useRichTextEditorStyle: Boolean,
|
||||||
): MessageTextItem? {
|
): MessageTextItem? {
|
||||||
val renderedBody = textRenderer.render(body)
|
val renderedBody = textRenderer.render(body)
|
||||||
val bindingOptions = spanUtils.getBindingOptions(renderedBody)
|
val bindingOptions = spanUtils.getBindingOptions(renderedBody)
|
||||||
|
@ -640,6 +646,7 @@ class MessageItemFactory @Inject constructor(
|
||||||
.previewUrlRetriever(callback?.getPreviewUrlRetriever())
|
.previewUrlRetriever(callback?.getPreviewUrlRetriever())
|
||||||
.imageContentRenderer(imageContentRenderer)
|
.imageContentRenderer(imageContentRenderer)
|
||||||
.previewUrlCallback(callback)
|
.previewUrlCallback(callback)
|
||||||
|
.useRichTextEditorStyle(useRichTextEditorStyle)
|
||||||
.leftGuideline(avatarSizeProvider.leftGuideline)
|
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||||
.attributes(attributes)
|
.attributes(attributes)
|
||||||
.highlighted(highlight)
|
.highlighted(highlight)
|
||||||
|
|
|
@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.item
|
||||||
|
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.text.method.MovementMethod
|
import android.text.method.MovementMethod
|
||||||
|
import android.view.ViewStub
|
||||||
import androidx.appcompat.widget.AppCompatTextView
|
import androidx.appcompat.widget.AppCompatTextView
|
||||||
import androidx.core.text.PrecomputedTextCompat
|
import androidx.core.text.PrecomputedTextCompat
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
@ -67,6 +68,9 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
|
||||||
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
||||||
var markwonPlugins: (List<MarkwonPlugin>)? = null
|
var markwonPlugins: (List<MarkwonPlugin>)? = null
|
||||||
|
|
||||||
|
@EpoxyAttribute
|
||||||
|
var useRichTextEditorStyle: Boolean = false
|
||||||
|
|
||||||
private val previewUrlViewUpdater = PreviewUrlViewUpdater()
|
private val previewUrlViewUpdater = PreviewUrlViewUpdater()
|
||||||
|
|
||||||
override fun bind(holder: Holder) {
|
override fun bind(holder: Holder) {
|
||||||
|
@ -82,27 +86,28 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
|
||||||
holder.previewUrlView.delegate = previewUrlCallback
|
holder.previewUrlView.delegate = previewUrlCallback
|
||||||
holder.previewUrlView.renderMessageLayout(attributes.informationData.messageLayout)
|
holder.previewUrlView.renderMessageLayout(attributes.informationData.messageLayout)
|
||||||
|
|
||||||
|
val messageView: AppCompatTextView = if (useRichTextEditorStyle) holder.richMessageView else holder.plainMessageView
|
||||||
if (useBigFont) {
|
if (useBigFont) {
|
||||||
holder.messageView.textSize = 44F
|
messageView.textSize = 44F
|
||||||
} else {
|
} else {
|
||||||
holder.messageView.textSize = 15.5F
|
messageView.textSize = 15.5F
|
||||||
}
|
}
|
||||||
if (searchForPills) {
|
if (searchForPills) {
|
||||||
message?.charSequence?.findPillsAndProcess(coroutineScope) {
|
message?.charSequence?.findPillsAndProcess(coroutineScope) {
|
||||||
// mmm.. not sure this is so safe in regards to cell reuse
|
// mmm.. not sure this is so safe in regards to cell reuse
|
||||||
it.bind(holder.messageView)
|
it.bind(messageView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message?.charSequence.let { charSequence ->
|
message?.charSequence.let { charSequence ->
|
||||||
markwonPlugins?.forEach { plugin -> plugin.beforeSetText(holder.messageView, charSequence as Spanned) }
|
markwonPlugins?.forEach { plugin -> plugin.beforeSetText(messageView, charSequence as Spanned) }
|
||||||
}
|
}
|
||||||
super.bind(holder)
|
super.bind(holder)
|
||||||
holder.messageView.movementMethod = movementMethod
|
messageView.movementMethod = movementMethod
|
||||||
renderSendState(holder.messageView, holder.messageView)
|
renderSendState(messageView, messageView)
|
||||||
holder.messageView.onClick(attributes.itemClickListener)
|
messageView.onClick(attributes.itemClickListener)
|
||||||
holder.messageView.onLongClickIgnoringLinks(attributes.itemLongClickListener)
|
messageView.onLongClickIgnoringLinks(attributes.itemLongClickListener)
|
||||||
holder.messageView.setTextWithEmojiSupport(message?.charSequence, bindingOptions)
|
messageView.setTextWithEmojiSupport(message?.charSequence, bindingOptions)
|
||||||
markwonPlugins?.forEach { plugin -> plugin.afterSetText(holder.messageView) }
|
markwonPlugins?.forEach { plugin -> plugin.afterSetText(messageView) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AppCompatTextView.setTextWithEmojiSupport(message: CharSequence?, bindingOptions: BindingOptions?) {
|
private fun AppCompatTextView.setTextWithEmojiSupport(message: CharSequence?, bindingOptions: BindingOptions?) {
|
||||||
|
@ -125,8 +130,15 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
|
||||||
override fun getViewStubId() = STUB_ID
|
override fun getViewStubId() = STUB_ID
|
||||||
|
|
||||||
class Holder : AbsMessageItem.Holder(STUB_ID) {
|
class Holder : AbsMessageItem.Holder(STUB_ID) {
|
||||||
val messageView by bind<AppCompatTextView>(R.id.messageTextView)
|
|
||||||
val previewUrlView by bind<PreviewUrlView>(R.id.messageUrlPreview)
|
val previewUrlView by bind<PreviewUrlView>(R.id.messageUrlPreview)
|
||||||
|
private val richMessageStub by bind<ViewStub>(R.id.richMessageTextViewStub)
|
||||||
|
private val plainMessageStub by bind<ViewStub>(R.id.plainMessageTextViewStub)
|
||||||
|
val richMessageView: AppCompatTextView by lazy {
|
||||||
|
richMessageStub.inflate().findViewById(R.id.messageTextView)
|
||||||
|
}
|
||||||
|
val plainMessageView: AppCompatTextView by lazy {
|
||||||
|
plainMessageStub.inflate().findViewById(R.id.messageTextView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class PreviewUrlViewUpdater : PreviewUrlRetriever.PreviewUrlRetrieverListener {
|
inner class PreviewUrlViewUpdater : PreviewUrlRetriever.PreviewUrlRetrieverListener {
|
||||||
|
|
|
@ -30,6 +30,8 @@ import android.content.res.Resources
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.text.Spannable
|
import android.text.Spannable
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.core.text.toSpannable
|
import androidx.core.text.toSpannable
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.RequestBuilder
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
@ -38,6 +40,7 @@ import im.vector.app.core.di.ActiveSessionHolder
|
||||||
import im.vector.app.core.resources.ColorProvider
|
import im.vector.app.core.resources.ColorProvider
|
||||||
import im.vector.app.core.utils.DimensionConverter
|
import im.vector.app.core.utils.DimensionConverter
|
||||||
import im.vector.app.features.settings.VectorPreferences
|
import im.vector.app.features.settings.VectorPreferences
|
||||||
|
import io.element.android.wysiwyg.spans.InlineCodeSpan
|
||||||
import io.noties.markwon.AbstractMarkwonPlugin
|
import io.noties.markwon.AbstractMarkwonPlugin
|
||||||
import io.noties.markwon.Markwon
|
import io.noties.markwon.Markwon
|
||||||
import io.noties.markwon.MarkwonPlugin
|
import io.noties.markwon.MarkwonPlugin
|
||||||
|
@ -64,8 +67,8 @@ import javax.inject.Singleton
|
||||||
@Singleton
|
@Singleton
|
||||||
class EventHtmlRenderer @Inject constructor(
|
class EventHtmlRenderer @Inject constructor(
|
||||||
htmlConfigure: MatrixHtmlPluginConfigure,
|
htmlConfigure: MatrixHtmlPluginConfigure,
|
||||||
context: Context,
|
private val context: Context,
|
||||||
vectorPreferences: VectorPreferences,
|
private val vectorPreferences: VectorPreferences,
|
||||||
private val activeSessionHolder: ActiveSessionHolder
|
private val activeSessionHolder: ActiveSessionHolder
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -73,73 +76,121 @@ class EventHtmlRenderer @Inject constructor(
|
||||||
fun afterRender(renderedText: Spannable)
|
fun afterRender(renderedText: Spannable)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val builder = Markwon.builder(context)
|
private val glidePlugin = GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore {
|
||||||
.usePlugin(HtmlPlugin.create(htmlConfigure))
|
override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
|
||||||
.usePlugin(GlideImagesPlugin.create(object : GlideImagesPlugin.GlideStore {
|
val url = drawable.destination
|
||||||
override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
|
if (url.isMxcUrl()) {
|
||||||
val url = drawable.destination
|
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
|
||||||
if (url.isMxcUrl()) {
|
val imageUrl = contentUrlResolver.resolveFullSize(url)
|
||||||
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
|
// Override size to avoid crashes for huge pictures
|
||||||
val imageUrl = contentUrlResolver.resolveFullSize(url)
|
return Glide.with(context).load(imageUrl).override(500)
|
||||||
// Override size to avoid crashes for huge pictures
|
}
|
||||||
return Glide.with(context).load(imageUrl).override(500)
|
// We don't want to support other url schemes here, so just return a request for null
|
||||||
}
|
return Glide.with(context).load(null as String?)
|
||||||
// We don't want to support other url schemes here, so just return a request for null
|
}
|
||||||
return Glide.with(context).load(null as String?)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cancel(target: Target<*>) {
|
override fun cancel(target: Target<*>) {
|
||||||
Glide.with(context).clear(target)
|
Glide.with(context).clear(target)
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
|
|
||||||
private val markwon = if (vectorPreferences.latexMathsIsEnabled()) {
|
private val latexPlugins = listOf(
|
||||||
// If latex maths is enabled in app preferences, refomat it so Markwon recognises it
|
object : AbstractMarkwonPlugin() {
|
||||||
// It needs to be in this specific format: https://noties.io/Markwon/docs/v4/ext-latex
|
override fun processMarkdown(markdown: String): String {
|
||||||
builder
|
return markdown
|
||||||
.usePlugin(object : AbstractMarkwonPlugin() {
|
.replace(Regex("""<span\s+data-mx-maths="([^"]*)">.*?</span>""")) { matchResult ->
|
||||||
override fun processMarkdown(markdown: String): String {
|
"$$" + matchResult.groupValues[1] + "$$"
|
||||||
return markdown
|
}
|
||||||
.replace(Regex("""<span\s+data-mx-maths="([^"]*)">.*?</span>""")) { matchResult ->
|
.replace(Regex("""<div\s+data-mx-maths="([^"]*)">.*?</div>""")) { matchResult ->
|
||||||
"$$" + matchResult.groupValues[1] + "$$"
|
"\n$$\n" + matchResult.groupValues[1] + "\n$$\n"
|
||||||
}
|
}
|
||||||
.replace(Regex("""<div\s+data-mx-maths="([^"]*)">.*?</div>""")) { matchResult ->
|
|
||||||
"\n$$\n" + matchResult.groupValues[1] + "\n$$\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.usePlugin(JLatexMathPlugin.create(44F) { builder ->
|
|
||||||
builder.inlinesEnabled(true)
|
|
||||||
builder.theme().inlinePadding(JLatexMathTheme.Padding.symmetric(24, 8))
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
builder
|
|
||||||
}
|
|
||||||
.usePlugin(
|
|
||||||
MarkwonInlineParserPlugin.create(
|
|
||||||
/* Configuring the Markwon inline formatting processor.
|
|
||||||
* Default settings are all Markdown features. Turn those off, only using the
|
|
||||||
* inline HTML processor and HTML entities processor.
|
|
||||||
*/
|
|
||||||
MarkwonInlineParser.factoryBuilderNoDefaults()
|
|
||||||
.addInlineProcessor(HtmlInlineProcessor()) // use inline HTML processor
|
|
||||||
.addInlineProcessor(EntityInlineProcessor()) // use HTML entities processor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.usePlugin(object : AbstractMarkwonPlugin() {
|
|
||||||
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
|
|
||||||
builder.setFactory(
|
|
||||||
Emphasis::class.java
|
|
||||||
) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) }
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
JLatexMathPlugin.create(44F) { builder ->
|
||||||
|
builder.inlinesEnabled(true)
|
||||||
|
builder.theme().inlinePadding(JLatexMathTheme.Padding.symmetric(24, 8))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
override fun configureParser(builder: Parser.Builder) {
|
private val markwonInlineParserPlugin =
|
||||||
/* Configuring the Markwon block formatting processor.
|
MarkwonInlineParserPlugin.create(
|
||||||
* Default settings are all Markdown blocks. Turn those off.
|
/* Configuring the Markwon inline formatting processor.
|
||||||
|
* Default settings are all Markdown features. Turn those off, only using the
|
||||||
|
* inline HTML processor and HTML entities processor.
|
||||||
*/
|
*/
|
||||||
builder.enabledBlockTypes(kotlin.collections.emptySet())
|
MarkwonInlineParser.factoryBuilderNoDefaults()
|
||||||
|
.addInlineProcessor(HtmlInlineProcessor()) // use inline HTML processor
|
||||||
|
.addInlineProcessor(EntityInlineProcessor()) // use HTML entities processor
|
||||||
|
)
|
||||||
|
|
||||||
|
private val italicPlugin = object : AbstractMarkwonPlugin() {
|
||||||
|
override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) {
|
||||||
|
builder.setFactory(
|
||||||
|
Emphasis::class.java
|
||||||
|
) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configureParser(builder: Parser.Builder) {
|
||||||
|
/* Configuring the Markwon block formatting processor.
|
||||||
|
* Default settings are all Markdown blocks. Turn those off.
|
||||||
|
*/
|
||||||
|
builder.enabledBlockTypes(emptySet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cleanUpIntermediateCodePlugin = object : AbstractMarkwonPlugin() {
|
||||||
|
override fun afterSetText(textView: TextView) {
|
||||||
|
super.afterSetText(textView)
|
||||||
|
|
||||||
|
// Remove any intermediate spans
|
||||||
|
val text = textView.text.toSpannable()
|
||||||
|
text.getSpans(0, text.length, IntermediateCodeSpan::class.java)
|
||||||
|
.forEach { span ->
|
||||||
|
text.removeSpan(span)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workaround for https://github.com/noties/Markwon/issues/423
|
||||||
|
*/
|
||||||
|
private val removeLeadingNewlineForInlineCode = object : AbstractMarkwonPlugin() {
|
||||||
|
override fun afterSetText(textView: TextView) {
|
||||||
|
super.afterSetText(textView)
|
||||||
|
|
||||||
|
val text = SpannableStringBuilder(textView.text.toSpannable())
|
||||||
|
val inlineCodeSpans = text.getSpans(0, textView.length(), InlineCodeSpan::class.java).toList()
|
||||||
|
val legacyInlineCodeSpans = text.getSpans(0, textView.length(), HtmlCodeSpan::class.java).filter { !it.isBlock }
|
||||||
|
val spans = inlineCodeSpans + legacyInlineCodeSpans
|
||||||
|
|
||||||
|
if (spans.isEmpty()) return
|
||||||
|
|
||||||
|
spans.forEach { span ->
|
||||||
|
val start = text.getSpanStart(span)
|
||||||
|
if (text[start] == '\n') {
|
||||||
|
text.replace(start, start + 1, "")
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
textView.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val markwon = Markwon.builder(context)
|
||||||
|
.usePlugin(HtmlRootTagPlugin())
|
||||||
|
.usePlugin(HtmlPlugin.create(htmlConfigure))
|
||||||
|
.usePlugin(removeLeadingNewlineForInlineCode)
|
||||||
|
.usePlugin(glidePlugin)
|
||||||
|
.apply {
|
||||||
|
if (vectorPreferences.latexMathsIsEnabled()) {
|
||||||
|
// If latex maths is enabled in app preferences, refomat it so Markwon recognises it
|
||||||
|
// It needs to be in this specific format: https://noties.io/Markwon/docs/v4/ext-latex
|
||||||
|
latexPlugins.forEach(::usePlugin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.usePlugin(markwonInlineParserPlugin)
|
||||||
|
.usePlugin(italicPlugin)
|
||||||
|
.usePlugin(cleanUpIntermediateCodePlugin)
|
||||||
.textSetter(PrecomputedFutureTextSetterCompat.create())
|
.textSetter(PrecomputedFutureTextSetterCompat.create())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@ -185,7 +236,11 @@ class EventHtmlRenderer @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: ColorProvider, private val resources: Resources) : HtmlPlugin.HtmlConfigure {
|
class MatrixHtmlPluginConfigure @Inject constructor(
|
||||||
|
private val colorProvider: ColorProvider,
|
||||||
|
private val resources: Resources,
|
||||||
|
private val vectorPreferences: VectorPreferences,
|
||||||
|
) : HtmlPlugin.HtmlConfigure {
|
||||||
|
|
||||||
override fun configureHtml(plugin: HtmlPlugin) {
|
override fun configureHtml(plugin: HtmlPlugin) {
|
||||||
plugin
|
plugin
|
||||||
|
@ -193,6 +248,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C
|
||||||
.addHandler(FontTagHandler())
|
.addHandler(FontTagHandler())
|
||||||
.addHandler(ParagraphHandler(DimensionConverter(resources)))
|
.addHandler(ParagraphHandler(DimensionConverter(resources)))
|
||||||
.addHandler(MxReplyTagHandler())
|
.addHandler(MxReplyTagHandler())
|
||||||
|
.addHandler(CodePostProcessorTagHandler(vectorPreferences))
|
||||||
.addHandler(CodePreTagHandler())
|
.addHandler(CodePreTagHandler())
|
||||||
.addHandler(CodeTagHandler())
|
.addHandler(CodeTagHandler())
|
||||||
.addHandler(SpanHandler(colorProvider))
|
.addHandler(SpanHandler(colorProvider))
|
||||||
|
|
|
@ -16,20 +16,29 @@
|
||||||
|
|
||||||
package im.vector.app.features.html
|
package im.vector.app.features.html
|
||||||
|
|
||||||
|
import im.vector.app.features.settings.VectorPreferences
|
||||||
|
import io.element.android.wysiwyg.spans.InlineCodeSpan
|
||||||
import io.noties.markwon.MarkwonVisitor
|
import io.noties.markwon.MarkwonVisitor
|
||||||
import io.noties.markwon.SpannableBuilder
|
import io.noties.markwon.SpannableBuilder
|
||||||
|
import io.noties.markwon.core.MarkwonTheme
|
||||||
import io.noties.markwon.html.HtmlTag
|
import io.noties.markwon.html.HtmlTag
|
||||||
import io.noties.markwon.html.MarkwonHtmlRenderer
|
import io.noties.markwon.html.MarkwonHtmlRenderer
|
||||||
import io.noties.markwon.html.TagHandler
|
import io.noties.markwon.html.TagHandler
|
||||||
|
|
||||||
class CodeTagHandler : TagHandler() {
|
/**
|
||||||
|
* Span to be added to any <code> found during initial pass.
|
||||||
|
* The actual code spans can then be added on a second pass using this
|
||||||
|
* span as a reference.
|
||||||
|
*/
|
||||||
|
internal class IntermediateCodeSpan(
|
||||||
|
var isBlock: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class CodeTagHandler : TagHandler() {
|
||||||
|
|
||||||
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
|
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
|
||||||
SpannableBuilder.setSpans(
|
SpannableBuilder.setSpans(
|
||||||
visitor.builder(),
|
visitor.builder(), IntermediateCodeSpan(isBlock = false), tag.start(), tag.end()
|
||||||
HtmlCodeSpan(visitor.configuration().theme(), false),
|
|
||||||
tag.start(),
|
|
||||||
tag.end()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,15 +51,13 @@ class CodeTagHandler : TagHandler() {
|
||||||
* Pre tag are already handled by HtmlPlugin to keep the formatting.
|
* Pre tag are already handled by HtmlPlugin to keep the formatting.
|
||||||
* We are only using it to check for <pre><code>*</code></pre> tags.
|
* We are only using it to check for <pre><code>*</code></pre> tags.
|
||||||
*/
|
*/
|
||||||
class CodePreTagHandler : TagHandler() {
|
internal class CodePreTagHandler : TagHandler() {
|
||||||
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
|
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
|
||||||
val htmlCodeSpan = visitor.builder()
|
val codeSpan = visitor.builder().getSpans(tag.start(), tag.end()).firstOrNull {
|
||||||
.getSpans(tag.start(), tag.end())
|
it.what is IntermediateCodeSpan
|
||||||
.firstOrNull {
|
}
|
||||||
it.what is HtmlCodeSpan
|
if (codeSpan != null) {
|
||||||
}
|
(codeSpan.what as IntermediateCodeSpan).isBlock = true
|
||||||
if (htmlCodeSpan != null) {
|
|
||||||
(htmlCodeSpan.what as HtmlCodeSpan).isBlock = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,3 +65,42 @@ class CodePreTagHandler : TagHandler() {
|
||||||
return listOf("pre")
|
return listOf("pre")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class CodePostProcessorTagHandler(
|
||||||
|
private val vectorPreferences: VectorPreferences,
|
||||||
|
) : TagHandler() {
|
||||||
|
|
||||||
|
override fun supportedTags() = listOf(HtmlRootTagPlugin.ROOT_TAG_NAME)
|
||||||
|
|
||||||
|
override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
|
||||||
|
if (tag.attributes()[HtmlRootTagPlugin.ROOT_ATTRIBUTE] == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag.isBlock) {
|
||||||
|
visitChildren(visitor, renderer, tag.asBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace any intermediate code spans with the real formatting spans
|
||||||
|
visitor.builder()
|
||||||
|
.getSpans(tag.start(), tag.end())
|
||||||
|
.filter {
|
||||||
|
it.what is IntermediateCodeSpan
|
||||||
|
}.forEach { code ->
|
||||||
|
val intermediateCodeSpan = code.what as IntermediateCodeSpan
|
||||||
|
val theme = visitor.configuration().theme()
|
||||||
|
val span = intermediateCodeSpan.toFinalCodeSpan(theme)
|
||||||
|
SpannableBuilder.setSpans(
|
||||||
|
visitor.builder(), span, code.start, code.end
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntermediateCodeSpan.toFinalCodeSpan(
|
||||||
|
markwonTheme: MarkwonTheme
|
||||||
|
): Any = if (vectorPreferences.isRichTextEditorEnabled() && !isBlock) {
|
||||||
|
InlineCodeSpan()
|
||||||
|
} else {
|
||||||
|
HtmlCodeSpan(markwonTheme, isBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 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.app.features.html
|
||||||
|
|
||||||
|
import io.noties.markwon.AbstractMarkwonPlugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A root node enables post-processing of optionally nested tags.
|
||||||
|
* See: [im.vector.app.features.html.CodePostProcessorTagHandler]
|
||||||
|
*/
|
||||||
|
internal class HtmlRootTagPlugin : AbstractMarkwonPlugin() {
|
||||||
|
companion object {
|
||||||
|
const val ROOT_ATTRIBUTE = "data-root"
|
||||||
|
const val ROOT_TAG_NAME = "div"
|
||||||
|
}
|
||||||
|
override fun processMarkdown(html: String): String {
|
||||||
|
return "<$ROOT_TAG_NAME $ROOT_ATTRIBUTE>$html</$ROOT_TAG_NAME>"
|
||||||
|
}
|
||||||
|
}
|
15
vector/src/main/res/drawable/ic_composer_inline_code.xml
Normal file
15
vector/src/main/res/drawable/ic_composer_inline_code.xml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="44dp"
|
||||||
|
android:height="44dp"
|
||||||
|
android:viewportWidth="44"
|
||||||
|
android:viewportHeight="44">
|
||||||
|
<path
|
||||||
|
android:pathData="M24.958,15.621C25.117,15.092 24.816,14.534 24.287,14.375C23.758,14.217 23.201,14.517 23.042,15.046L19.042,28.379C18.883,28.908 19.184,29.466 19.713,29.624C20.242,29.783 20.799,29.483 20.958,28.954L24.958,15.621Z"
|
||||||
|
android:fillColor="#8D97A5"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M15.974,17.232C15.549,16.878 14.919,16.936 14.565,17.36L11.232,21.36C10.923,21.731 10.923,22.269 11.232,22.64L14.565,26.64C14.919,27.065 15.549,27.122 15.974,26.768C16.398,26.415 16.455,25.784 16.102,25.36L13.302,22L16.102,18.64C16.455,18.216 16.398,17.585 15.974,17.232Z"
|
||||||
|
android:fillColor="#8D97A5"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M28.027,17.232C28.451,16.878 29.081,16.936 29.435,17.36L32.768,21.36C33.077,21.731 33.077,22.269 32.768,22.64L29.435,26.64C29.081,27.065 28.451,27.122 28.027,26.768C27.602,26.415 27.545,25.784 27.898,25.36L30.698,22L27.898,18.64C27.545,18.216 27.602,17.585 28.027,17.232Z"
|
||||||
|
android:fillColor="#8D97A5"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Message view to be used when the rich text editor is not enabled -->
|
||||||
|
<androidx.appcompat.widget.AppCompatTextView android:id="@+id/messageTextView"
|
||||||
|
style="@style/Widget.Vector.TextView.Body"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAlignment="viewStart"
|
||||||
|
android:textColor="?vctr_content_primary"
|
||||||
|
tools:text="@sample/messages.json/data/message"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools" />
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Message view to be used when the rich text editor is enabled -->
|
||||||
|
<io.element.android.wysiwyg.EditorStyledTextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/messageTextView"
|
||||||
|
style="@style/Widget.Vector.TextView.Body"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAlignment="viewStart"
|
||||||
|
android:textColor="?vctr_content_primary"
|
||||||
|
tools:text="@sample/messages.json/data/message" />
|
|
@ -7,14 +7,17 @@
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
tools:viewBindingIgnore="true">
|
tools:viewBindingIgnore="true">
|
||||||
|
|
||||||
<TextView
|
<ViewStub
|
||||||
android:id="@+id/messageTextView"
|
android:id="@+id/plainMessageTextViewStub"
|
||||||
style="@style/Widget.Vector.TextView.Body"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textAlignment="viewStart"
|
android:layout="@layout/item_timeline_event_text_message_plain_stub" />
|
||||||
android:textColor="?vctr_content_primary"
|
|
||||||
tools:text="@sample/messages.json/data/message" />
|
<ViewStub
|
||||||
|
android:id="@+id/richMessageTextViewStub"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout="@layout/item_timeline_event_text_message_rich_stub" />
|
||||||
|
|
||||||
<im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView
|
<im.vector.app.features.home.room.detail.timeline.url.PreviewUrlView
|
||||||
android:id="@+id/messageUrlPreview"
|
android:id="@+id/messageUrlPreview"
|
||||||
|
|
Loading…
Reference in a new issue