Experimental: Allow sending custom emotes ("emojis")

Using MSC2545 image packs

TODO:
- not use pills, or make them look differently here?
- edits and drafts lose it
    note: upstream issue, same for user pills

Change-Id: I27daf5835e32b818e512b61b57c09bea8c205e94
This commit is contained in:
SpiritCroc 2022-06-05 13:17:21 +02:00
parent 7f9a3dfbe6
commit fd34eba596
14 changed files with 181 additions and 22 deletions

View File

@ -109,6 +109,9 @@ object EventType {
val POLL_RESPONSE = listOf("org.matrix.msc3381.poll.response", "m.poll.response")
val POLL_END = listOf("org.matrix.msc3381.poll.end", "m.poll.end")
// Emotes
const val ROOM_EMOTES = "im.ponies.room_emotes"
// Unwedging
internal const val DUMMY = "m.dummy"

View File

@ -0,0 +1,13 @@
package org.matrix.android.sdk.api.session.room.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.session.room.model.message.ImageInfo
@JsonClass(generateAdapter = true)
data class EmoteImage(
@Json(name = "url") val url: String,
@Json(name = "body") val body: String? = null,
@Json(name = "info") val info: ImageInfo? = null,
@Json(name = "usage") val usage: List<String>? = null,
)

View File

@ -0,0 +1,19 @@
package org.matrix.android.sdk.api.session.room.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.session.room.powerlevels.Role
/**
* Class representing the EventType.ROOM_EMOTE state event content.
*/
@JsonClass(generateAdapter = true)
data class RoomEmoteContent(
@Json(name = "images") val images: Map<String, EmoteImage>? = null,
// TODO: "pack" support
) {
companion object {
const val USAGE_EMOTICON = "emoticon"
const val USAGE_STICKER = "sticker"
}
}

View File

@ -112,6 +112,13 @@ sealed class MatrixItem(
override fun updateAvatar(newAvatar: String?) = copy(avatarUrl = newAvatar)
}
data class EmoteItem(override val id: String,
override val displayName: String? = null,
override val avatarUrl: String? = null) :
MatrixItem(id, displayName, avatarUrl) {
override fun updateAvatar(newAvatar: String?) = copy(avatarUrl = newAvatar)
}
protected fun checkId() {
if (!id.startsWith(getIdPrefix())) {
error("Wrong usage of MatrixItem: check the id $id should start with ${getIdPrefix()}")
@ -131,6 +138,7 @@ sealed class MatrixItem(
is EveryoneInRoomItem -> '!'
is RoomAliasItem -> '#'
is GroupItem -> '+'
is EmoteItem -> 'm'
}
fun firstLetterOfDisplayName(): String {

View File

@ -63,7 +63,8 @@ internal class MarkdownParser @Inject constructor(
htmlText
}
return if (isFormattedTextPertinent(source, cleanHtmlText)) {
// SC-note: upstream checks with "source" instead of "text.toString()", but this breaks with stuff like "a :turtle:", where :turtle: is a custom emote
return if (isFormattedTextPertinent(text.toString(), cleanHtmlText)) {
// According to https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes:
// The plain text version of the HTML should be provided in the body.
// But it caused too many problems so it has been removed in #2002

View File

@ -70,7 +70,15 @@ internal class TextPillsUtils @Inject constructor(
// append text before pill
append(text, currIndex, start)
// append the pill
append(String.format(template, urlSpan.matrixItem.id, displayNameResolver.getBestName(urlSpan.matrixItem)))
// Different handling necessary for custom emotes
if (urlSpan.matrixItem is MatrixItem.EmoteItem) {
// Note we use the same template for both HTML and MARKDOWN conversion. We do this since markdown inline images are not mighty enough
// for custom emotes (i.e., that would drop the data-mx-emoticon tag, which we want to keep). But we can use inline html in markdown.
val imgHtml = "<img data-mx-emoticon height=\"18\" src=\"${urlSpan.matrixItem.avatarUrl}\" title=\":${urlSpan.matrixItem.displayName}:\" alt=\":${urlSpan.matrixItem.displayName}:\">"
append(imgHtml)
} else {
append(String.format(template, urlSpan.matrixItem.id, displayNameResolver.getBestName(urlSpan.matrixItem)))
}
currIndex = end
}
// append text after the last pill
@ -111,3 +119,14 @@ internal class TextPillsUtils @Inject constructor(
}
}
}
fun CharSequence.requiresFormattedMessage(): Boolean {
val spannableString = SpannableString.valueOf(this)
val pills = spannableString
?.getSpans(0, length, MatrixItemSpan::class.java)
?.map { MentionLinkSpec(it, spannableString.getSpanStart(it), spannableString.getSpanEnd(it)) }
// We cannot send emotes without markdown/formatted messages
?.filter { it.span.matrixItem is MatrixItem.EmoteItem }
?: return false
return pills.isNotEmpty()
}

View File

@ -21,11 +21,15 @@ import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.EmojiCompatFontProvider
import im.vector.app.features.autocomplete.AutocompleteClickListener
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.reactions.data.EmojiItem
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import javax.inject.Inject
class AutocompleteEmojiController @Inject constructor(
private val fontProvider: EmojiCompatFontProvider
private val fontProvider: EmojiCompatFontProvider,
private val session: Session
) : TypedEpoxyController<List<EmojiItem>>() {
var emojiTypeface: Typeface? = fontProvider.typeface
@ -36,7 +40,7 @@ class AutocompleteEmojiController @Inject constructor(
}
}
var listener: AutocompleteClickListener<String>? = null
var listener: AutocompleteClickListener<EmojiItem>? = null
override fun buildModels(data: List<EmojiItem>?) {
if (data.isNullOrEmpty()) {
@ -49,8 +53,11 @@ class AutocompleteEmojiController @Inject constructor(
autocompleteEmojiItem {
id(emojiItem.name)
emojiItem(emojiItem)
// For caching reasons, we use the AvatarRenderer's thumbnail size here
emoteUrl(host.session.contentUrlResolver().resolveThumbnail(emojiItem.mxcUrl,
AvatarRenderer.THUMBNAIL_SIZE, AvatarRenderer.THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE))
emojiTypeFace(host.emojiTypeface)
onClickListener { host.listener?.onItemClick(emojiItem.emoji) }
onClickListener { host.listener?.onItemClick(emojiItem) }
}
}

View File

@ -17,7 +17,9 @@
package im.vector.app.features.autocomplete.emoji
import android.graphics.Typeface
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
@ -26,7 +28,9 @@ import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.glide.GlideApp
import im.vector.app.features.reactions.data.EmojiItem
import org.matrix.android.sdk.api.extensions.orFalse
@EpoxyModelClass(layout = R.layout.item_autocomplete_emoji)
abstract class AutocompleteEmojiItem : VectorEpoxyModel<AutocompleteEmojiItem.Holder>() {
@ -34,6 +38,9 @@ abstract class AutocompleteEmojiItem : VectorEpoxyModel<AutocompleteEmojiItem.Ho
@EpoxyAttribute
lateinit var emojiItem: EmojiItem
@EpoxyAttribute
var emoteUrl: String? = null
@EpoxyAttribute
var emojiTypeFace: Typeface? = null
@ -42,7 +49,18 @@ abstract class AutocompleteEmojiItem : VectorEpoxyModel<AutocompleteEmojiItem.Ho
override fun bind(holder: Holder) {
super.bind(holder)
holder.emojiText.text = emojiItem.emoji
if (emoteUrl?.isNotEmpty().orFalse()) {
holder.emojiText.isVisible = false
holder.emoteImage.isVisible = true
GlideApp.with(holder.emoteImage)
.load(emoteUrl)
.centerCrop()
.into(holder.emoteImage)
} else {
holder.emojiText.text = emojiItem.emoji
holder.emojiText.isVisible = true
holder.emoteImage.isVisible = false
}
holder.emojiText.typeface = emojiTypeFace ?: Typeface.DEFAULT
holder.emojiNameText.text = emojiItem.name
holder.emojiKeywordText.setTextOrHide(emojiItem.keywords.joinToString())
@ -51,6 +69,7 @@ abstract class AutocompleteEmojiItem : VectorEpoxyModel<AutocompleteEmojiItem.Ho
class Holder : VectorEpoxyHolder() {
val emojiText by bind<TextView>(R.id.itemAutocompleteEmoji)
val emoteImage by bind<ImageView>(R.id.itemAutocompleteEmote)
val emojiNameText by bind<TextView>(R.id.itemAutocompleteEmojiName)
val emojiKeywordText by bind<TextView>(R.id.itemAutocompleteEmojiSubname)
}

View File

@ -18,20 +18,35 @@ package im.vector.app.features.autocomplete.emoji
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.autocomplete.AutocompleteClickListener
import im.vector.app.features.autocomplete.RecyclerViewPresenter
import im.vector.app.features.reactions.data.EmojiDataSource
import im.vector.app.features.reactions.data.EmojiItem
import im.vector.app.features.settings.VectorPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import javax.inject.Inject
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.getStateEvent
import org.matrix.android.sdk.api.session.room.model.RoomEmoteContent
class AutocompleteEmojiPresenter @Inject constructor(context: Context,
private val emojiDataSource: EmojiDataSource,
private val controller: AutocompleteEmojiController) :
RecyclerViewPresenter<String>(context), AutocompleteClickListener<String> {
class AutocompleteEmojiPresenter @AssistedInject constructor(context: Context,
@Assisted val roomId: String,
private val session: Session,
private val vectorPreferences: VectorPreferences,
private val emojiDataSource: EmojiDataSource,
private val controller: AutocompleteEmojiController) :
RecyclerViewPresenter<EmojiItem>(context), AutocompleteClickListener<EmojiItem> {
private val room by lazy { session.getRoom(roomId)!! }
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@ -44,23 +59,45 @@ class AutocompleteEmojiPresenter @Inject constructor(context: Context,
controller.listener = null
}
@AssistedFactory
interface Factory {
fun create(roomId: String): AutocompleteEmojiPresenter
}
override fun instantiateAdapter(): RecyclerView.Adapter<*> {
return controller.adapter
}
override fun onItemClick(t: String) {
override fun onItemClick(t: EmojiItem) {
dispatchClick(t)
}
override fun onQuery(query: CharSequence?) {
coroutineScope.launch {
// Plain emojis
val data = if (query.isNullOrBlank()) {
// Return common emojis
emojiDataSource.getQuickReactions()
} else {
emojiDataSource.filterWith(query.toString())
}
controller.setData(data)
// Custom emotes
// TODO may want to add headers (compare @room vs @person completion) for
// - Standard emojis
// - Room-specific emotes
// - Global emotes (exported from other rooms)
val images = room.getStateEvent(EventType.ROOM_EMOTES)?.content?.toModel<RoomEmoteContent>()?.images.orEmpty()
val emoteData = images.filter {
val usages = it.value.usage
usages.isNullOrEmpty() || RoomEmoteContent.USAGE_EMOTICON in usages
}.filter {
query == null || it.key.contains(query, true)
}.map {
EmojiItem(it.key, "", mxcUrl = it.value.url)
}.sortedBy { it.name }.distinct()
controller.setData(emoteData + data)
}
}
}

View File

@ -60,7 +60,7 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
private val dimensionConverter: DimensionConverter) {
companion object {
private const val THUMBNAIL_SIZE = 250
const val THUMBNAIL_SIZE = 250
}
@UiThread

View File

@ -40,6 +40,7 @@ import im.vector.app.features.command.Command
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.html.PillImageSpan
import im.vector.app.features.reactions.data.EmojiItem
import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.session.group.model.GroupSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
@ -55,12 +56,14 @@ class AutoCompleter @AssistedInject constructor(
private val commandAutocompletePolicy: CommandAutocompletePolicy,
autocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory,
private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory,
private val autocompleteEmojiPresenterFactory: AutocompleteEmojiPresenter.Factory,
private val autocompleteRoomPresenter: AutocompleteRoomPresenter,
private val autocompleteGroupPresenter: AutocompleteGroupPresenter,
private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter
//private val autocompleteEmojiPresenter: AutocompleteEmojiPresenter
) {
private lateinit var autocompleteMemberPresenter: AutocompleteMemberPresenter
private lateinit var autocompleteEmojiPresenter: AutocompleteEmojiPresenter
@AssistedFactory
interface Factory {
@ -189,13 +192,14 @@ class AutoCompleter @AssistedInject constructor(
}
private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) {
Autocomplete.on<String>(editText)
autocompleteEmojiPresenter = autocompleteEmojiPresenterFactory.create(roomId)
Autocomplete.on<EmojiItem>(editText)
.with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false))
.with(autocompleteEmojiPresenter)
.with(ELEVATION_DP)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<String> {
override fun onPopupItemClicked(editable: Editable, item: String): Boolean {
.with(object : AutocompleteCallback<EmojiItem> {
override fun onPopupItemClicked(editable: Editable, item: EmojiItem): Boolean {
// Infer that the last ":" before the current cursor position is the original popup trigger
var startIndex = editable.subSequence(0, editText.selectionStart).lastIndexOf(":")
if (startIndex == -1) {
@ -210,7 +214,25 @@ class AutoCompleter @AssistedInject constructor(
// Replace the word by its completion
editable.delete(startIndex, endIndex)
editable.insert(startIndex, item)
if (item.mxcUrl.isNotEmpty()) {
// Add emote html
val emote = ":${item.name}:"
editable.insert(startIndex, emote)
// Add span to make it look nice
val matrixItem = MatrixItem.EmoteItem(item.mxcUrl, item.name, item.mxcUrl)
val span = PillImageSpan(
glideRequests,
avatarRenderer,
editText.context,
matrixItem
)
span.bind(editText)
editable.setSpan(span, startIndex, startIndex + emote.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} else {
editable.insert(startIndex, item.emoji)
}
return true
}

View File

@ -80,7 +80,6 @@ import de.spiritcroc.recyclerview.widget.BetterLinearLayoutManager
import de.spiritcroc.recyclerview.widget.LinearLayoutManager
import im.vector.app.R
import im.vector.app.core.animations.play
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.dialogs.ConfirmationDialogBuilder
import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper
import im.vector.app.core.epoxy.LayoutManagerStateRestorer
@ -264,6 +263,7 @@ import org.matrix.android.sdk.api.session.widgets.model.WidgetType
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.internal.session.room.send.pills.requiresFormattedMessage
import reactivecircus.flowbinding.android.view.focusChanges
import reactivecircus.flowbinding.android.widget.textChanges
import timber.log.Timber
@ -1852,7 +1852,8 @@ class TimelineFragment @Inject constructor(
// We collapse ASAP, if not there will be a slight annoying delay
views.composerLayout.collapse(true)
lockSendButton = true
messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, vectorPreferences.isMarkdownEnabled()))
val forceMarkdown = text.requiresFormattedMessage()
messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, forceMarkdown || vectorPreferences.isMarkdownEnabled()))
emojiPopup.dismiss()
if (vectorPreferences.jumpToBottomOnSend()) {

View File

@ -41,7 +41,8 @@ import com.squareup.moshi.JsonClass
data class EmojiItem(
@Json(name = "a") val name: String,
@Json(name = "b") val unicode: String,
@Json(name = "j") val keywords: List<String> = emptyList()
@Json(name = "j") val keywords: List<String> = emptyList(),
val mxcUrl: String = ""
) {
// Cannot be private...
var cache: String? = null

View File

@ -19,6 +19,15 @@
tools:ignore="SpUsage"
tools:text="@sample/reactions.json/data/reaction" />
<ImageView
android:id="@+id/itemAutocompleteEmote"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginHorizontal="4dp"
android:gravity="center"
android:layout_gravity="center"
tools:ignore="ContentDescription" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"