Commit e2c4ebf6 authored by Leonardo Aramaki's avatar Leonardo Aramaki

Show custom emojis on messages (including animated gifs)

parent cfcc4f62
...@@ -115,6 +115,8 @@ dependencies { ...@@ -115,6 +115,8 @@ dependencies {
implementation libraries.frescoWebP implementation libraries.frescoWebP
implementation libraries.frescoAnimatedWebP implementation libraries.frescoAnimatedWebP
implementation libraries.glide
kapt libraries.kotshiCompiler kapt libraries.kotshiCompiler
implementation libraries.kotshiApi implementation libraries.kotshiApi
......
package chat.rocket.android.chatroom.adapter package chat.rocket.android.chatroom.adapter
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Drawable
import android.text.Spannable
import android.text.Spanned
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.text.style.ImageSpan
import android.view.View import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.postDelayed
import chat.rocket.android.R import chat.rocket.android.R
import chat.rocket.android.chatroom.uimodel.MessageUiModel import chat.rocket.android.chatroom.uimodel.MessageUiModel
import chat.rocket.android.emoji.EmojiReactionListener import chat.rocket.android.emoji.EmojiReactionListener
import chat.rocket.core.model.isSystemMessage import chat.rocket.core.model.isSystemMessage
import com.bumptech.glide.load.resource.gif.GifDrawable
import kotlinx.android.synthetic.main.avatar.view.* import kotlinx.android.synthetic.main.avatar.view.*
import kotlinx.android.synthetic.main.item_message.view.* import kotlinx.android.synthetic.main.item_message.view.*
...@@ -15,7 +21,24 @@ class MessageViewHolder( ...@@ -15,7 +21,24 @@ class MessageViewHolder(
itemView: View, itemView: View,
listener: ActionsListener, listener: ActionsListener,
reactionListener: EmojiReactionListener? = null reactionListener: EmojiReactionListener? = null
) : BaseViewHolder<MessageUiModel>(itemView, listener, reactionListener) { ) : BaseViewHolder<MessageUiModel>(itemView, listener, reactionListener), Drawable.Callback {
override fun unscheduleDrawable(who: Drawable?, what: Runnable?) {
with(itemView) {
text_content.removeCallbacks(what)
}
}
override fun invalidateDrawable(p0: Drawable?) {
with(itemView) {
text_content.invalidate()
}
}
override fun scheduleDrawable(who: Drawable?, what: Runnable?, w: Long) {
with(itemView) {
text_content.postDelayed(what, w)
}
}
init { init {
with(itemView) { with(itemView) {
...@@ -31,15 +54,30 @@ class MessageViewHolder( ...@@ -31,15 +54,30 @@ class MessageViewHolder(
text_message_time.text = data.time text_message_time.text = data.time
text_sender.text = data.senderName text_sender.text = data.senderName
text_content.text = data.content
if (data.content is Spannable) {
val spans = data.content.getSpans(0, data.content.length, ImageSpan::class.java)
spans.forEach {
if (it.drawable is GifDrawable) {
it.drawable.callback = this@MessageViewHolder
(it.drawable as GifDrawable).start()
}
}
}
text_content.text_content.text = data.content
image_avatar.setImageURI(data.avatar) image_avatar.setImageURI(data.avatar)
text_content.setTextColor( text_content.setTextColor(
if (data.isTemporary) Color.GRAY else Color.BLACK if (data.isTemporary) Color.GRAY else Color.BLACK
) )
data.message.let { data.message.let {
text_edit_indicator.isVisible = !it.isSystemMessage() && it.editedBy != null text_edit_indicator.isVisible = !it.isSystemMessage() && it.editedBy != null
image_star_indicator.isVisible = it.starred?.isNotEmpty() ?: false image_star_indicator.isVisible = it.starred?.isNotEmpty() ?: false
} }
if (data.unread == null) { if (data.unread == null) {
read_receipt_view.isVisible = false read_receipt_view.isVisible = false
} else { } else {
......
...@@ -618,7 +618,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR ...@@ -618,7 +618,7 @@ class ChatRoomFragment : Fragment(), ChatRoomView, EmojiKeyboardListener, EmojiR
override fun onEmojiAdded(emoji: Emoji) { override fun onEmojiAdded(emoji: Emoji) {
val cursorPosition = text_message.selectionStart val cursorPosition = text_message.selectionStart
if (cursorPosition > -1) { if (cursorPosition > -1) {
text_message.text?.insert(cursorPosition, EmojiParser.parse(emoji.shortname)) text_message.text?.insert(cursorPosition, EmojiParser.parse(context!!, emoji.shortname))
text_message.setSelection(cursorPosition + emoji.unicode.length) text_message.setSelection(cursorPosition + emoji.unicode.length)
} }
} }
......
...@@ -457,7 +457,7 @@ class UiModelMapper @Inject constructor( ...@@ -457,7 +457,7 @@ class UiModelMapper @Inject constructor(
list.add( list.add(
ReactionUiModel(messageId = message.id, ReactionUiModel(messageId = message.id,
shortname = shortname, shortname = shortname,
unicode = EmojiParser.parse(shortname), unicode = EmojiParser.parse(context, shortname),
count = count, count = count,
usernames = usernames) usernames = usernames)
) )
......
...@@ -9,6 +9,7 @@ import android.net.Uri ...@@ -9,6 +9,7 @@ import android.net.Uri
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import android.text.Spanned import android.text.Spanned
import android.text.style.ClickableSpan import android.text.style.ClickableSpan
import android.text.style.ImageSpan
import android.text.style.ReplacementSpan import android.text.style.ReplacementSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import android.util.Patterns import android.util.Patterns
...@@ -62,11 +63,11 @@ class MessageParser @Inject constructor( ...@@ -62,11 +63,11 @@ class MessageParser @Inject constructor(
} }
} }
val builder = SpannableBuilder() val builder = SpannableBuilder()
val content = EmojiRepository.shortnameToUnicode(text, true) val content = EmojiRepository.shortnameToUnicode(text)
val parentNode = parser.parse(toLenientMarkdown(content)) val parentNode = parser.parse(toLenientMarkdown(content))
parentNode.accept(MarkdownVisitor(configuration, builder)) parentNode.accept(MarkdownVisitor(configuration, builder))
parentNode.accept(LinkVisitor(builder)) parentNode.accept(LinkVisitor(builder))
parentNode.accept(EmojiVisitor(configuration, builder)) parentNode.accept(EmojiVisitor(context, configuration, builder))
message.mentions?.let { message.mentions?.let {
parentNode.accept(MentionVisitor(context, builder, mentions, selfUsername)) parentNode.accept(MentionVisitor(context, builder, mentions, selfUsername))
} }
...@@ -128,17 +129,22 @@ class MessageParser @Inject constructor( ...@@ -128,17 +129,22 @@ class MessageParser @Inject constructor(
} }
class EmojiVisitor( class EmojiVisitor(
private val context: Context,
configuration: SpannableConfiguration, configuration: SpannableConfiguration,
private val builder: SpannableBuilder private val builder: SpannableBuilder
) : SpannableMarkdownVisitor(configuration, builder) { ) : SpannableMarkdownVisitor(configuration, builder) {
override fun visit(document: Document) { override fun visit(document: Document) {
val spannable = EmojiParser.parse(builder.text()) val spannable = EmojiParser.parse(context, builder.text())
if (spannable is Spanned) { if (spannable is Spanned) {
val spans = spannable.getSpans(0, spannable.length, EmojiTypefaceSpan::class.java) val spans = spannable.getSpans(0, spannable.length, EmojiTypefaceSpan::class.java)
val spans2 = spannable.getSpans(0, spannable.length, ImageSpan::class.java)
spans.forEach { spans.forEach {
builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it), 0) builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it), 0)
} }
spans2.forEach {
builder.setSpan(it, spannable.getSpanStart(it), spannable.getSpanEnd(it), 0)
}
} }
} }
} }
......
...@@ -66,12 +66,13 @@ var TextView.content: CharSequence? ...@@ -66,12 +66,13 @@ var TextView.content: CharSequence?
Markwon.unscheduleDrawables(this) Markwon.unscheduleDrawables(this)
Markwon.unscheduleTableRows(this) Markwon.unscheduleTableRows(this)
if (value is Spanned) { if (value is Spanned) {
val result = EmojiParser.parse(value.toString()) as Spannable val context = this.context
val result = EmojiParser.parse(context, value.toString()) as Spannable
val end = if (value.length > result.length) result.length else value.length val end = if (value.length > result.length) result.length else value.length
TextUtils.copySpansFrom(value, 0, end, Any::class.java, result, 0) TextUtils.copySpansFrom(value, 0, end, Any::class.java, result, 0)
text = result text = result
} else { } else {
val result = EmojiParser.parse(value.toString()) as Spannable val result = EmojiParser.parse(context, value.toString()) as Spannable
text = result text = result
} }
Markwon.scheduleDrawables(this) Markwon.scheduleDrawables(this)
......
...@@ -46,6 +46,7 @@ ext { ...@@ -46,6 +46,7 @@ ext {
frescoImageViewer : '0.5.1', frescoImageViewer : '0.5.1',
markwon : '1.0.6', markwon : '1.0.6',
aVLoadingIndicatorView: '2.1.3', aVLoadingIndicatorView: '2.1.3',
glide : '4.7.1',
// For wearable // For wearable
wear : '2.3.0', wear : '2.3.0',
...@@ -107,6 +108,8 @@ ext { ...@@ -107,6 +108,8 @@ ext {
kotshiCompiler : "se.ansman.kotshi:compiler:${versions.kotshi}", kotshiCompiler : "se.ansman.kotshi:compiler:${versions.kotshi}",
frescoImageViewer : "com.github.luciofm:FrescoImageViewer:${versions.frescoImageViewer}", frescoImageViewer : "com.github.luciofm:FrescoImageViewer:${versions.frescoImageViewer}",
glide : "com.github.bumptech.glide:glide:${versions.glide}",
glideProcessor : "com.github.bumptech.glide:compiler:${versions.glide}",
markwon : "ru.noties:markwon:${versions.markwon}", markwon : "ru.noties:markwon:${versions.markwon}",
......
...@@ -34,8 +34,8 @@ dependencies { ...@@ -34,8 +34,8 @@ dependencies {
implementation libraries.constraintlayout implementation libraries.constraintlayout
implementation libraries.recyclerview implementation libraries.recyclerview
implementation libraries.material implementation libraries.material
implementation 'com.github.bumptech.glide:glide:4.7.1' implementation libraries.glide
annotationProcessor 'com.github.bumptech.glide:compiler:4.7.1' annotationProcessor libraries.glideProcessor
} }
kotlin { kotlin {
......
...@@ -81,42 +81,42 @@ class EmojiKeyboardPopup(context: Context, view: View) : OverKeyboardPopupWindow ...@@ -81,42 +81,42 @@ class EmojiKeyboardPopup(context: Context, view: View) : OverKeyboardPopupWindow
.create() .create()
view.findViewById<TextView>(R.id.default_tone_text).also { view.findViewById<TextView>(R.id.default_tone_text).also {
it.text = EmojiParser.parse(it.text) it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener { }.setOnClickListener {
dialog.dismiss() dialog.dismiss()
changeSkinTone(Fitzpatrick.Default) changeSkinTone(Fitzpatrick.Default)
} }
view.findViewById<TextView>(R.id.light_tone_text).also { view.findViewById<TextView>(R.id.light_tone_text).also {
it.text = EmojiParser.parse(it.text) it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener { }.setOnClickListener {
dialog.dismiss() dialog.dismiss()
changeSkinTone(Fitzpatrick.LightTone) changeSkinTone(Fitzpatrick.LightTone)
} }
view.findViewById<TextView>(R.id.medium_light_text).also { view.findViewById<TextView>(R.id.medium_light_text).also {
it.text = EmojiParser.parse(it.text) it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener { }.setOnClickListener {
dialog.dismiss() dialog.dismiss()
changeSkinTone(Fitzpatrick.MediumLightTone) changeSkinTone(Fitzpatrick.MediumLightTone)
} }
view.findViewById<TextView>(R.id.medium_tone_text).also { view.findViewById<TextView>(R.id.medium_tone_text).also {
it.text = EmojiParser.parse(it.text) it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener { }.setOnClickListener {
dialog.dismiss() dialog.dismiss()
changeSkinTone(Fitzpatrick.MediumTone) changeSkinTone(Fitzpatrick.MediumTone)
} }
view.findViewById<TextView>(R.id.medium_dark_tone_text).also { view.findViewById<TextView>(R.id.medium_dark_tone_text).also {
it.text = EmojiParser.parse(it.text) it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener { }.setOnClickListener {
dialog.dismiss() dialog.dismiss()
changeSkinTone(Fitzpatrick.MediumDarkTone) changeSkinTone(Fitzpatrick.MediumDarkTone)
} }
view.findViewById<TextView>(R.id.dark_tone_text).also { view.findViewById<TextView>(R.id.dark_tone_text).also {
it.text = EmojiParser.parse(it.text) it.text = EmojiParser.parse(context, it.text)
}.setOnClickListener { }.setOnClickListener {
dialog.dismiss() dialog.dismiss()
changeSkinTone(Fitzpatrick.DarkTone) changeSkinTone(Fitzpatrick.DarkTone)
......
package chat.rocket.android.emoji package chat.rocket.android.emoji
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.Spanned import android.text.Spanned
import android.text.style.ImageSpan
import android.util.Log
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.gif.GifDrawable
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
class EmojiParser { class EmojiParser {
companion object { companion object {
private val regex = ":[\\w]+:".toRegex()
/** /**
* Parses a text string containing unicode characters and/or shortnames to a rendered * Parses a text string containing unicode characters and/or shortnames to a rendered
* Spannable. * Spannable.
...@@ -15,9 +30,11 @@ class EmojiParser { ...@@ -15,9 +30,11 @@ class EmojiParser {
* @param factory Optional. A [Spannable.Factory] instance to reuse when creating [Spannable]. * @param factory Optional. A [Spannable.Factory] instance to reuse when creating [Spannable].
* @return A rendered Spannable containing any supported emoji. * @return A rendered Spannable containing any supported emoji.
*/ */
fun parse(text: CharSequence, factory: Spannable.Factory? = null): CharSequence { fun parse(context: Context, text: CharSequence, factory: Spannable.Factory? = null): CharSequence {
val unicodedText = EmojiRepository.shortnameToUnicode(text, true) val unicodedText = EmojiRepository.shortnameToUnicode(text)
val spannable = factory?.newSpannable(unicodedText) ?: SpannableString.valueOf(unicodedText) val spannable = factory?.newSpannable(unicodedText)
?: SpannableString.valueOf(unicodedText)
val typeface = EmojiRepository.cachedTypeface val typeface = EmojiRepository.cachedTypeface
// Look for groups of emojis, set a EmojiTypefaceSpan with the emojione font. // Look for groups of emojis, set a EmojiTypefaceSpan with the emojione font.
val length = spannable.length val length = spannable.length
...@@ -50,7 +67,43 @@ class EmojiParser { ...@@ -50,7 +67,43 @@ class EmojiParser {
emojiStart, offset, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) emojiStart, offset, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} }
} }
return spannable
val customEmojis = EmojiRepository.getCustomEmojis()
val density = context.resources.displayMetrics.density
val px = (24 * density).toInt()
return spannable.also {
regex.findAll(spannable).iterator().forEach { match ->
customEmojis.find { it.shortname.toLowerCase() == match.value.toLowerCase() }?.let {
it.url?.let { url ->
try {
val glideRequest = if (url.endsWith("gif", true)) {
Glide.with(context).asGif()
} else {
Glide.with(context).asBitmap()
}
val futureTarget = glideRequest.load(url).submit(px, px)
val range = match.range
futureTarget.get()?.let { image ->
if (image is Bitmap) {
spannable.setSpan(ImageSpan(context, image), range.start,
range.endInclusive + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
} else if (image is GifDrawable) {
image.setBounds(0, 0, px, px)
spannable.setSpan(ImageSpan(image), range.start,
range.endInclusive + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
} catch (ex: Throwable) {
Log.e("EmojiParser", "", ex)
}
}
}
}
}
} }
} }
} }
...@@ -21,10 +21,12 @@ object EmojiRepository { ...@@ -21,10 +21,12 @@ object EmojiRepository {
private val shortNameToUnicode = HashMap<String, String>() private val shortNameToUnicode = HashMap<String, String>()
private val SHORTNAME_PATTERN = Pattern.compile(":([-+\\w]+):") private val SHORTNAME_PATTERN = Pattern.compile(":([-+\\w]+):")
private val ALL_EMOJIS = mutableListOf<Emoji>() private val ALL_EMOJIS = mutableListOf<Emoji>()
private var customEmojis: List<Emoji> = emptyList()
private lateinit var preferences: SharedPreferences private lateinit var preferences: SharedPreferences
internal lateinit var cachedTypeface: Typeface internal lateinit var cachedTypeface: Typeface
fun load(context: Context, customEmojis: List<Emoji> = emptyList(), path: String = "emoji.json") { fun load(context: Context, customEmojis: List<Emoji> = emptyList(), path: String = "emoji.json") {
this.customEmojis = customEmojis
preferences = context.getSharedPreferences("emoji", Context.MODE_PRIVATE) preferences = context.getSharedPreferences("emoji", Context.MODE_PRIVATE)
ALL_EMOJIS.clear() ALL_EMOJIS.clear()
cachedTypeface = Typeface.createFromAsset(context.assets, "fonts/emojione-android.ttf") cachedTypeface = Typeface.createFromAsset(context.assets, "fonts/emojione-android.ttf")
...@@ -133,6 +135,8 @@ object EmojiRepository { ...@@ -133,6 +135,8 @@ object EmojiRepository {
preferences.edit().putString(PREF_EMOJI_RECENTS, recentsJson.toString()).apply() preferences.edit().putString(PREF_EMOJI_RECENTS, recentsJson.toString()).apply()
} }
internal fun getCustomEmojis(): List<Emoji> = customEmojis
/** /**
* Get all recently used emojis ordered by usage count. * Get all recently used emojis ordered by usage count.
* *
...@@ -157,7 +161,7 @@ object EmojiRepository { ...@@ -157,7 +161,7 @@ object EmojiRepository {
/** /**
* Replace shortnames to unicode characters. * Replace shortnames to unicode characters.
*/ */
fun shortnameToUnicode(input: CharSequence, removeIfUnsupported: Boolean): String { fun shortnameToUnicode(input: CharSequence): String {
val matcher = SHORTNAME_PATTERN.matcher(input) val matcher = SHORTNAME_PATTERN.matcher(input)
var result: String = input.toString() var result: String = input.toString()
......
...@@ -151,7 +151,7 @@ internal class EmojiPagerAdapter(private val listener: EmojiKeyboardListener) : ...@@ -151,7 +151,7 @@ internal class EmojiPagerAdapter(private val listener: EmojiKeyboardListener) :
val parsedUnicode = unicodeCache[emoji.unicode] val parsedUnicode = unicodeCache[emoji.unicode]
emoji_view.setSpannableFactory(spannableFactory) emoji_view.setSpannableFactory(spannableFactory)
emoji_view.text = if (parsedUnicode == null) { emoji_view.text = if (parsedUnicode == null) {
EmojiParser.parse(emoji.unicode, spannableFactory).let { EmojiParser.parse(itemView.context, emoji.unicode, spannableFactory).let {
unicodeCache[emoji.unicode] = it unicodeCache[emoji.unicode] = it
it it
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment