MessageParser.kt 8.32 KB
Newer Older
1 2 3 4 5 6 7 8 9 10
package chat.rocket.android.helper

import android.app.Application
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.text.Layout
11
import android.text.Spannable
12
import android.text.Spanned
13
import android.text.TextPaint
14
import android.text.style.*
15
import android.view.View
16 17 18 19 20 21 22
import chat.rocket.android.R
import chat.rocket.android.chatroom.viewmodel.MessageViewModel
import org.commonmark.node.BlockQuote
import ru.noties.markwon.Markwon
import ru.noties.markwon.SpannableBuilder
import ru.noties.markwon.SpannableConfiguration
import ru.noties.markwon.renderer.SpannableMarkdownVisitor
23
import java.util.regex.Pattern
24 25 26 27 28
import javax.inject.Inject

class MessageParser @Inject constructor(val context: Application, private val configuration: SpannableConfiguration) {

    private val parser = Markwon.createParser()
29 30 31
    private val regexUsername = Pattern.compile("([^\\S]|^)+(@[\\w.]+)",
            Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
    private val regexLink = Pattern.compile("(https?:\\/\\/)?(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&/=]*)",
32
            Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
33

34 35 36 37 38 39 40 41 42
    /**
     * Render a markdown text message to Spannable.
     *
     * @param text The text message containing markdown syntax.
     * @param quote An optional message to be quoted either by a quote or reply action.
     * @param urls A list of urls to convert to markdown link syntax.
     *
     * @return A Spannable with the parsed markdown.
     */
43
    fun renderMarkdown(text: String, quote: MessageViewModel? = null, selfUsername: String? = null): CharSequence {
44 45 46
        val builder = SpannableBuilder()
        var content: String = text

47
        // Replace all url links to markdown url syntax.
48 49 50 51 52 53 54 55
        val matcher = regexLink.matcher(content)
        val consumed = mutableListOf<String>()
        while (matcher.find()) {
            val link = matcher.group(0)
            // skip usernames
            if (!link.startsWith("@") && !consumed.contains(link)) {
                content = content.replace(link, "[$link]($link)")
                consumed.add(link)
56
            }
57 58 59
        }

        val parentNode = parser.parse(toLenientMarkdown(content))
60
        parentNode.accept(QuoteMessageBodyVisitor(context, configuration, builder))
61
        quote?.apply {
62
            var quoteNode = parser.parse("> $senderName $time")
63
            parentNode.appendChild(quoteNode)
64
            quoteNode.accept(QuoteMessageSenderVisitor(context, configuration, builder, senderName.length))
65 66 67 68
            quoteNode = parser.parse("> ${toLenientMarkdown(quote.getOriginalMessage())}")
            quoteNode.accept(QuoteMessageBodyVisitor(context, configuration, builder))
        }

69
        val result = builder.text()
70
        applySpans(result, selfUsername)
71 72 73
        return result
    }

74 75
    private fun applySpans(text: CharSequence, currentUser: String?) {
        val matcher = regexUsername.matcher(text)
76 77
        val result = text as Spannable
        while (matcher.find()) {
Leonardo Aramaki's avatar
Leonardo Aramaki committed
78 79
            val user = matcher.group(2)
            val start = matcher.start(2)
80
            //TODO: should check if username actually exists prior to applying.
81 82 83 84 85
            val linkColor = context.resources.getColor(R.color.linkTextColor)
            val linkBackgroundColor = context.resources.getColor(R.color.linkBackgroundColor)
            val referSelf = currentUser != null && "@$currentUser" == user
            val usernameSpan = UsernameClickableSpan(linkBackgroundColor, linkColor, referSelf)
            result.setSpan(usernameSpan, start, start + user.length, 0)
86
        }
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
    }

    /**
     * Convert to a lenient markdown consistent with Rocket.Chat web markdown instead of the official specs.
     */
    private fun toLenientMarkdown(text: String): String {
        return text.trim().replace("\\*(.+)\\*".toRegex()) { "**${it.groupValues[1].trim()}**" }
                .replace("\\~(.+)\\~".toRegex()) { "~~${it.groupValues[1].trim()}~~" }
                .replace("\\_(.+)\\_".toRegex()) { "_${it.groupValues[1].trim()}_" }
    }

    class QuoteMessageSenderVisitor(private val context: Context,
                                    configuration: SpannableConfiguration,
                                    private val builder: SpannableBuilder,
                                    private val senderNameLength: Int) : SpannableMarkdownVisitor(configuration, builder) {

        override fun visit(blockQuote: BlockQuote) {

            // mark current length
            val length = builder.length()

            // pass to super to apply markdown
            super.visit(blockQuote)

            val res = context.resources
            val timeOffsetStart = length + senderNameLength + 1
            builder.setSpan(QuoteMarginSpan(context.getDrawable(R.drawable.quote), 10), length, builder.length())
            builder.setSpan(StyleSpan(Typeface.BOLD), length, length + senderNameLength)
            builder.setSpan(ForegroundColorSpan(Color.BLACK), length, builder.length())
            // set time spans
            builder.setSpan(AbsoluteSizeSpan(res.getDimensionPixelSize(R.dimen.message_time_text_size)),
                    timeOffsetStart, builder.length())
            builder.setSpan(ForegroundColorSpan(res.getColor(R.color.darkGray)),
                    timeOffsetStart, builder.length())
        }
    }

    class QuoteMessageBodyVisitor(private val context: Context,
                                  configuration: SpannableConfiguration,
                                  private val builder: SpannableBuilder) : SpannableMarkdownVisitor(configuration, builder) {

        override fun visit(blockQuote: BlockQuote) {

            // mark current length
            val length = builder.length()

            // pass to super to apply markdown
            super.visit(blockQuote)

            builder.setSpan(QuoteMarginSpan(context.getDrawable(R.drawable.quote), 10), length, builder.length())
        }
    }

140
    class QuoteMarginSpan(quoteDrawable: Drawable, private var pad: Int) : LeadingMarginSpan, LineHeightSpan {
141
        private val drawable: Drawable = quoteDrawable
142 143

        override fun getLeadingMargin(first: Boolean): Int {
144
            return drawable.intrinsicWidth + pad
145 146 147 148 149 150 151 152 153
        }

        override fun drawLeadingMargin(c: Canvas, p: Paint, x: Int, dir: Int,
                                       top: Int, baseline: Int, bottom: Int,
                                       text: CharSequence, start: Int, end: Int,
                                       first: Boolean, layout: Layout) {
            val st = (text as Spanned).getSpanStart(this)
            val ix = x
            val itop = layout.getLineTop(layout.getLineForOffset(st))
154 155
            val dw = drawable.intrinsicWidth
            val dh = drawable.intrinsicHeight
156
            // XXX What to do about Paint?
157 158
            drawable.setBounds(ix, itop, ix + dw, itop + layout.height)
            drawable.draw(c)
159 160 161
        }

        override fun chooseHeight(text: CharSequence, start: Int, end: Int,
162
                                  spanstartv: Int, v: Int,
163 164
                                  fm: Paint.FontMetricsInt) {
            if (end == (text as Spanned).getSpanEnd(this)) {
165
                val ht = drawable.intrinsicHeight
166
                var need = ht - (v + fm.descent - fm.ascent - spanstartv)
167 168
                if (need > 0)
                    fm.descent += need
169
                need = ht - (v + fm.bottom - fm.top - spanstartv)
170 171 172 173 174
                if (need > 0)
                    fm.bottom += need
            }
        }
    }
175

176 177 178 179
    class UsernameClickableSpan(private val linkBackgroundColor: Int,
                                private val linkTextColor: Int,
                                private val referSelf: Boolean) : ClickableSpan() {

180 181 182 183 184
        override fun onClick(widget: View) {
            //TODO: Implement action when clicking on username, like showing user profile.
        }

        override fun updateDrawState(ds: TextPaint) {
185 186 187 188 189 190 191 192
            if (referSelf) {
                ds.color = Color.WHITE
                ds.typeface = Typeface.DEFAULT_BOLD
                ds.bgColor = linkTextColor
            } else {
                ds.color = linkTextColor
                ds.bgColor = linkBackgroundColor
            }
193 194 195 196
            ds.isUnderlineText = false
        }

    }
197
}