MessageParser.kt 8.94 KB
Newer Older
1 2 3 4
package chat.rocket.android.helper

import android.app.Application
import android.content.Context
5 6 7
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
8
import android.text.Spanned
9 10
import android.text.style.ClickableSpan
import android.text.style.ReplacementSpan
11 12
import android.util.Patterns
import android.view.View
13
import androidx.core.content.res.ResourcesCompat
14
import chat.rocket.android.R
15 16 17
import chat.rocket.android.emoji.EmojiParser
import chat.rocket.android.emoji.EmojiRepository
import chat.rocket.android.emoji.EmojiTypefaceSpan
18 19 20
import chat.rocket.android.server.domain.PublicSettings
import chat.rocket.android.server.domain.useRealName
import chat.rocket.android.util.extensions.openTabbedUrl
21 22
import chat.rocket.common.model.SimpleUser
import chat.rocket.core.model.Message
23
import org.commonmark.node.AbstractVisitor
24
import org.commonmark.node.Document
Leonardo Aramaki's avatar
Leonardo Aramaki committed
25 26 27 28
import org.commonmark.node.ListItem
import org.commonmark.node.Node
import org.commonmark.node.OrderedList
import org.commonmark.node.Paragraph
29
import org.commonmark.node.Text
30 31 32 33 34 35
import ru.noties.markwon.Markwon
import ru.noties.markwon.SpannableBuilder
import ru.noties.markwon.SpannableConfiguration
import ru.noties.markwon.renderer.SpannableMarkdownVisitor
import javax.inject.Inject

36 37 38 39 40
class MessageParser @Inject constructor(
    private val context: Application,
    private val configuration: SpannableConfiguration,
    private val settings: PublicSettings
) {
41 42 43

    private val parser = Markwon.createParser()

44
    /**
45
     * Render markdown and other rules on message to rich text with spans.
46
     *
47 48
     * @param message The [Message] object we're interested on rendering.
     * @param selfUsername This user username.
49 50 51
     *
     * @return A Spannable with the parsed markdown.
     */
52 53 54 55 56 57 58 59 60 61
    fun render(message: Message, selfUsername: String? = null): CharSequence {
        var text: String = message.message
        val mentions = mutableListOf<String>()
        message.mentions?.forEach {
            val mention = getMention(it)
            mentions.add(mention)
            if (it.username != null) {
                text = text.replace("@${it.username}", mention)
            }
        }
62
        val builder = SpannableBuilder()
63
        val content = EmojiRepository.shortnameToUnicode(text, true)
64
        val parentNode = parser.parse(toLenientMarkdown(content))
Leonardo Aramaki's avatar
Leonardo Aramaki committed
65
        parentNode.accept(MarkdownVisitor(configuration, builder))
66
        parentNode.accept(LinkVisitor(builder))
67
        parentNode.accept(EmojiVisitor(configuration, builder))
68
        message.mentions?.let {
69
            parentNode.accept(MentionVisitor(context, builder, mentions, selfUsername))
70
        }
71 72

        return builder.text()
73 74
    }

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

82 83 84 85 86 87 88 89
    private fun getMention(user: SimpleUser): String {
        return if (settings.useRealName()) {
            user.name ?: "@${user.username}"
        } else {
            "@${user.username}"
        }
    }

90 91 92
    class MentionVisitor(
        context: Context,
        private val builder: SpannableBuilder,
93 94
        private val mentions: List<String>,
        private val currentUser: String?
95 96
    ) : AbstractVisitor() {

97 98
        private val othersTextColor = ResourcesCompat.getColor(context.resources, R.color.colorAccent, context.theme)
        private val othersBackgroundColor = ResourcesCompat.getColor(context.resources, android.R.color.transparent, context.theme)
99
        private val myselfTextColor = ResourcesCompat.getColor(context.resources, R.color.colorWhite, context.theme)
100 101 102
        private val myselfBackgroundColor = ResourcesCompat.getColor(context.resources, R.color.colorAccent, context.theme)
        private val mentionPadding = context.resources.getDimensionPixelSize(R.dimen.padding_mention).toFloat()
        private val mentionRadius = context.resources.getDimensionPixelSize(R.dimen.radius_mention).toFloat()
103

104 105
        override fun visit(t: Text) {
            val text = t.literal
106 107 108 109
            val mentionsList = mentions.toMutableList().also {
                it.add("@all")
                it.add("@here")
            }.toList()
110

111 112 113 114 115 116 117 118 119 120 121 122
            mentionsList.forEach {
                val mentionMe = it == currentUser || it == "@all" || it == "@here"
                var offset = text.indexOf(it, 0, true)
                while (offset > -1) {
                    val textColor = if (mentionMe) myselfTextColor else othersTextColor
                    val backgroundColor = if (mentionMe) myselfBackgroundColor else othersBackgroundColor
                    val usernameSpan = MentionSpan(backgroundColor, textColor, mentionRadius, mentionPadding,
                        mentionMe)
                    // Add 1 to end offset to include the @.
                    val end = offset + it.length + 1
                    builder.setSpan(usernameSpan, offset, end, 0)
                    offset = text.indexOf("@$it", end, true)
123 124 125 126 127
                }
            }
        }
    }

128 129 130 131 132
    class EmojiVisitor(
        configuration: SpannableConfiguration,
        private val builder: SpannableBuilder
    ) : SpannableMarkdownVisitor(configuration, builder) {

133 134
        override fun visit(document: Document) {
            val spannable = EmojiParser.parse(builder.text())
135 136 137
            if (spannable is Spanned) {
                val spans = spannable.getSpans(0, spannable.length, EmojiTypefaceSpan::class.java)
                spans.forEach {
138
                    builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it), 0)
139 140 141 142 143
                }
            }
        }
    }

Leonardo Aramaki's avatar
Leonardo Aramaki committed
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
    class MarkdownVisitor(
        configuration: SpannableConfiguration,
        val builder: SpannableBuilder
    ) : SpannableMarkdownVisitor(configuration, builder) {

        /**
         * NOOP
         */
        override fun visit(orderedList: OrderedList) {
            var number = orderedList.startNumber
            val delimiter = orderedList.delimiter
            var node: Node? = orderedList.firstChild
            while (node != null) {
                if (node is ListItem) {
                    newLine()
                    builder.append("$number$delimiter ")
160
                    super.visitChildren(node)
Leonardo Aramaki's avatar
Leonardo Aramaki committed
161 162 163 164 165 166 167 168 169 170 171 172 173 174
                    newLine()
                }
                number++
                node = node.next
            }
        }

        private fun newLine() {
            if (builder.length() > 0 && '\n' != builder.lastChar()) {
                builder.append('\n')
            }
        }
    }

175 176 177 178 179 180 181 182 183 184 185 186 187
    class LinkVisitor(private val builder: SpannableBuilder) : AbstractVisitor() {

        override fun visit(text: Text) {
            // Replace all url links to markdown url syntax.
            val matcher = Patterns.WEB_URL.matcher(builder.text())
            val consumed = mutableListOf<String>()

            while (matcher.find()) {
                val link = matcher.group(0)
                // skip usernames
                if (!link.startsWith("@") && link !in consumed) {
                    builder.setSpan(object : ClickableSpan() {
                        override fun onClick(view: View) {
188
                            view.openTabbedUrl(link)
189 190 191 192 193 194 195 196 197
                        }
                    }, matcher.start(0), matcher.end(0))
                    consumed.add(link)
                }
            }
            visitChildren(text)
        }
    }

198 199 200 201 202 203 204 205
    class MentionSpan(
        private val backgroundColor: Int,
        private val textColor: Int,
        private val radius: Float,
        padding: Float,
        referSelf: Boolean
    ) : ReplacementSpan() {

206 207 208 209 210 211 212 213
        private val padding: Float = if (referSelf) padding else 0F

        override fun getSize(paint: Paint,
                             text: CharSequence,
                             start: Int,
                             end: Int,
                             fm: Paint.FontMetricsInt?): Int {
            return (padding + paint.measureText(text.subSequence(start, end).toString()) + padding).toInt()
214 215
        }

216 217 218 219 220 221 222 223 224 225
        override fun draw(canvas: Canvas,
                          text: CharSequence,
                          start: Int,
                          end: Int,
                          x: Float,
                          top: Int,
                          y: Int,
                          bottom: Int,
                          paint: Paint) {
            val length = paint.measureText(text.subSequence(start, end).toString())
226
            val rect = RectF(x, top.toFloat(), x + length + padding * 2, bottom.toFloat())
227
            paint.color = backgroundColor
228
            canvas.drawRoundRect(rect, radius, radius, paint)
229
            paint.color = textColor
230
            canvas.drawText(text, start, end, x + padding, y.toFloat(), paint)
231 232
        }
    }
233
}