Send-as-sticker button for sticker-enabled custom emotes

Add some primitive support for sending MSC2545 stickers, at least for
stickers that also support sending as custom emote.
Also, this introduces support to sending stickers as reply this way 🎉

Change-Id: I85b245c2c40b9662342459e50285c081d37f324b
This commit is contained in:
SpiritCroc 2023-04-01 12:35:15 +02:00
parent dab8f0b51c
commit 5f787db4f1
14 changed files with 155 additions and 19 deletions

View file

@ -30,7 +30,7 @@ Here you can find some extra features and changes compared to Element Android (w
- Setting to not alert for new messages if there's still an old notification for that room
- Setting to hide start call buttons from the room's toolbar
- Render inline images / custom emojis in the timeline
- Allow sending custom emotes, if they have been set up with another compatible client ([MSC2545](https://github.com/matrix-org/matrix-spec-proposals/pull/2545))
- Allow sending custom emotes (and partly stickers), if they have been set up with another compatible client ([MSC2545](https://github.com/matrix-org/matrix-spec-proposals/pull/2545))
- Render image reactions
- Send freeform reactions
- Render media captions ([MSC2530](https://github.com/matrix-org/matrix-spec-proposals/pull/2530))

View file

@ -227,4 +227,6 @@
<!-- Note to translators: the translation MUST contain the string "${app_name_sc_stable}", which will be replaced by the application name -->
<string name="use_latest_app_sc">Use the latest ${app_name_sc_stable} on your other devices:</string>
<string name="action_send_as_sticker">Send as sticker</string>
</resources>

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.util
import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.room.model.EmoteImage
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.RoomType
@ -114,7 +115,9 @@ sealed class MatrixItem(
data class EmoteItem(override val id: String,
override val displayName: String? = null,
override val avatarUrl: String? = null) :
val emoteImage: EmoteImage,
override val avatarUrl: String? = emoteImage.url,
) :
MatrixItem(id, displayName, avatarUrl) {
override fun updateAvatar(newAvatar: String?) = copy(avatarUrl = newAvatar)
}

View file

@ -16,7 +16,9 @@
package org.matrix.android.sdk.internal.session.room.send.pills
import android.text.SpannableString
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.permalinks.PermalinkService
import org.matrix.android.sdk.api.session.room.model.RoomEmoteContent.Companion.USAGE_STICKER
import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.internal.session.displayname.DisplayNameResolver
@ -130,3 +132,23 @@ fun CharSequence.requiresFormattedMessage(): Boolean {
?: return false
return pills.isNotEmpty()
}
fun CharSequence.asSticker(): MatrixItem.EmoteItem? {
val spannableString = SpannableString.valueOf(this)
val emotes = spannableString
?.getSpans(0, length, MatrixItemSpan::class.java)
?.map { MentionLinkSpec(it, spannableString.getSpanStart(it), spannableString.getSpanEnd(it)) }
?.filter { it.span.matrixItem is MatrixItem.EmoteItem }
if (emotes?.size == 1) {
val emote = emotes[0]
if (emote.start != 0 || emote.end != length) {
return null
}
val emoteItem = emote.span.matrixItem as MatrixItem.EmoteItem
val emoteImage = emoteItem.emoteImage
if (emoteImage.usage?.contains(USAGE_STICKER).orTrue()) {
return emoteItem
}
}
return null
}

View file

@ -253,7 +253,7 @@ class AutocompleteEmojiPresenter @AssistedInject constructor(
}.filter {
query == null || it.key.contains(query, true)
}.map {
EmojiItem(it.key, "", mxcUrl = it.value.url)
EmojiItem(it.key, "", emoteImage = it.value)
}.sortedBy { it.name }.distinctBy { it.mxcUrl }
}

View file

@ -67,11 +67,16 @@ class AutoCompleter @AssistedInject constructor(
fun create(roomId: String, isInThreadTimeline: Boolean): AutoCompleter
}
interface Callback {
fun onAutoCompleteCustomEmote() {}
}
private val autocompleteCommandPresenter: AutocompleteCommandPresenter by lazy {
autocompleteCommandPresenterFactory.create(isInThreadTimeline)
}
private var editText: EditText? = null
private var callback: Callback? = null
fun enterSpecialMode() {
commandAutocompletePolicy.enabled = false
@ -83,8 +88,9 @@ class AutoCompleter @AssistedInject constructor(
private lateinit var glideRequests: GlideRequests
fun setup(editText: EditText) {
fun setup(editText: EditText, callback: Callback? = null) {
this.editText = editText
this.callback = callback
glideRequests = GlideApp.with(editText)
val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(editText.context, android.R.attr.colorBackground))
setupCommands(backgroundDrawable, editText)
@ -95,6 +101,7 @@ class AutoCompleter @AssistedInject constructor(
fun clear() {
this.editText = null
this.callback = null
autocompleteEmojiPresenter.clear()
autocompleteRoomPresenter.clear()
autocompleteCommandPresenter.clear()
@ -194,13 +201,13 @@ class AutoCompleter @AssistedInject constructor(
// Replace the word by its completion
editable.delete(startIndex, endIndex)
if (item.mxcUrl.isNotEmpty()) {
if (item.emoteImage != null) {
// 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 matrixItem = MatrixItem.EmoteItem(item.emoteImage.url, item.name, item.emoteImage)
val span = PillImageSpan(
glideRequests,
avatarRenderer,
@ -210,6 +217,7 @@ class AutoCompleter @AssistedInject constructor(
span.bind(editText)
editable.setSpan(span, startIndex, startIndex + emote.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
callback?.onAutoCompleteCustomEmote()
} else {
editable.insert(startIndex, item.emoji)
}

View file

@ -676,6 +676,9 @@ class TimelineViewModel @AssistedInject constructor(
private fun handleSendSticker(action: RoomDetailAction.SendSticker) {
if (room == null) return
val content = initialState.rootThreadEventId?.let {
// Some sticker action might already have set this correctly, and maybe also done a real reply
val actionRelatesTo = action.stickerContent.relatesTo
if (actionRelatesTo?.type != RelationType.THREAD || actionRelatesTo.eventId != it) {
action.stickerContent.copy(
relatesTo = RelationDefaultContent(
type = RelationType.THREAD,
@ -683,6 +686,9 @@ class TimelineViewModel @AssistedInject constructor(
eventId = it
)
)
} else {
action.stickerContent
}
} ?: action.stickerContent
room.sendService().sendEvent(EventType.STICKER, content.toContent())

View file

@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
sealed class MessageComposerAction : VectorViewModelAction {
data class SendMessage(val text: CharSequence, val formattedText: String?, val autoMarkdown: Boolean) : MessageComposerAction()
object PopDraft : MessageComposerAction() // SC
data class EnterEditMode(val eventId: String) : MessageComposerAction()
data class EnterQuoteMode(val eventId: String) : MessageComposerAction()
data class EnterReplyMode(val eventId: String) : MessageComposerAction()

View file

@ -103,6 +103,11 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.internal.session.room.send.pills.requiresFormattedMessage
import reactivecircus.flowbinding.android.view.focusChanges
@ -334,7 +339,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
composerEditText.setHint(R.string.room_message_placeholder)
if (!isRichTextEditorEnabled) {
autoCompleter.setup(composerEditText)
autoCompleter.setup(composerEditText, composer)
}
observerUserTyping()
@ -402,6 +407,40 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), A
}
}
override fun onSendSticker(sticker: MatrixItem.EmoteItem) = withState(messageComposerViewModel) { state ->
val image = sticker.emoteImage
val sendMode = state.sendMode
val relatesTo = if (sendMode is SendMode.Reply) {
state.rootThreadEventId?.let {
RelationDefaultContent(
type = RelationType.THREAD,
eventId = it,
isFallingBack = false, // sendMode is reply, this reply is intentional and not a thread fallback
inReplyTo = ReplyToContent(eventId = sendMode.timelineEvent.eventId)
)
} ?: RelationDefaultContent(null, null, ReplyToContent(eventId = sendMode.timelineEvent.eventId))
} else {
null
}
val stickerContent = MessageStickerContent(
body = image.body ?: sticker.displayName ?: sticker.id,
info = image.info,
url = image.url,
relatesTo = relatesTo,
)
timelineViewModel.handle(RoomDetailAction.SendSticker(stickerContent))
if (state.isFullScreen) {
messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false))
}
messageComposerViewModel.handle(MessageComposerAction.PopDraft)
emojiPopup.dismiss()
if (vectorPreferences.jumpToBottomOnSend()) {
timelineViewModel.handle(RoomDetailAction.JumpToBottom)
}
}
override fun onCloseRelatedMessage() {
messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(false))
}

View file

@ -19,9 +19,11 @@ package im.vector.app.features.home.room.detail.composer
import android.text.Editable
import android.widget.EditText
import android.widget.ImageButton
import im.vector.app.features.home.room.detail.AutoCompleter
import im.vector.app.features.home.room.detail.TimelineViewModel
import org.matrix.android.sdk.api.util.MatrixItem
interface MessageComposerView {
interface MessageComposerView : AutoCompleter.Callback {
companion object {
const val MAX_LINES_WHEN_COLLAPSED = 10
@ -43,6 +45,7 @@ interface MessageComposerView {
interface Callback : ComposerEditText.Callback {
fun onCloseRelatedMessage()
fun onSendMessage(text: CharSequence)
fun onSendSticker(sticker: MatrixItem.EmoteItem)
fun onAddAttachment()
fun onExpandOrCompactChange()
fun onFullScreenModeChanged()

View file

@ -144,6 +144,7 @@ class MessageComposerViewModel @AssistedInject constructor(
is MessageComposerAction.SetFullScreen -> handleSetFullScreen(action)
// SC
MessageComposerAction.ClearFocus -> _viewEvents.post(MessageComposerViewEvents.ClearFocus)
MessageComposerAction.PopDraft -> popDraft(room)
}
}

View file

@ -24,7 +24,6 @@ import android.util.AttributeSet
import android.widget.EditText
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.core.text.toSpannable
import androidx.core.view.isVisible
@ -54,6 +53,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromHtmlReply
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.internal.session.room.send.pills.asSticker
import javax.inject.Inject
/**
@ -76,6 +76,7 @@ class PlainTextComposerLayout @JvmOverloads constructor(
private val views: ComposerLayoutScBinding
override var callback: Callback? = null
private var modeSupportsSendAsSticker: Boolean = false
override val text: Editable?
get() = views.composerEditText.text
@ -110,6 +111,7 @@ class PlainTextComposerLayout @JvmOverloads constructor(
override fun onTextChanged(text: CharSequence) {
callback?.onTextChanged(text)
updateSendStickerVisibility()
}
}
views.composerRelatedMessageCloseButton.setOnClickListener {
@ -122,6 +124,11 @@ class PlainTextComposerLayout @JvmOverloads constructor(
callback?.onSendMessage(textMessage)
}
views.sendStickerButton.setOnClickListener {
val sticker = text?.asSticker() ?: return@setOnClickListener
callback?.onSendSticker(sticker)
}
views.attachmentButton.setOnClickListener {
callback?.onAddAttachment()
}
@ -129,6 +136,7 @@ class PlainTextComposerLayout @JvmOverloads constructor(
private fun collapse(transitionComplete: (() -> Unit)? = null) {
views.relatedMessageGroup.isVisible = false
updateSendStickerVisibility()
transitionComplete?.invoke()
callback?.onExpandOrCompactChange()
@ -137,21 +145,33 @@ class PlainTextComposerLayout @JvmOverloads constructor(
private fun expand(transitionComplete: (() -> Unit)? = null) {
views.relatedMessageGroup.isVisible = true
updateSendStickerVisibility()
transitionComplete?.invoke()
callback?.onExpandOrCompactChange()
views.attachmentButton.isVisible = false
}
private fun updateSendStickerVisibility() {
val canSendAsSticker = modeSupportsSendAsSticker && views.composerEditText.text?.asSticker() != null
views.sendStickerButtonDecor.isVisible = canSendAsSticker
views.sendStickerButton.isVisible = canSendAsSticker
}
override fun setTextIfDifferent(text: CharSequence?): Boolean {
return views.composerEditText.setTextIfDifferent(text)
}
override fun onAutoCompleteCustomEmote() {
updateSendStickerVisibility()
}
override fun renderComposerMode(mode: MessageComposerMode, timelineViewModel: TimelineViewModel?) {
val specialMode = mode as? MessageComposerMode.Special
if (specialMode != null) {
renderSpecialMode(specialMode, timelineViewModel)
} else if (mode is MessageComposerMode.Normal) {
modeSupportsSendAsSticker = true
collapse()
editText.setTextIfDifferent(mode.content)
}
@ -181,6 +201,7 @@ class PlainTextComposerLayout @JvmOverloads constructor(
)
private fun renderSpecialMode(specialMode: MessageComposerMode.Special, timelineViewModel: TimelineViewModel?) {
modeSupportsSendAsSticker = specialMode is MessageComposerMode.Reply
val event = specialMode.event
val defaultContent = specialMode.defaultContent

View file

@ -18,6 +18,7 @@ package im.vector.app.features.reactions.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.session.room.model.EmoteImage
/**
* Example:
@ -42,11 +43,13 @@ data class EmojiItem(
@Json(name = "a") val name: String,
@Json(name = "b") val unicode: String,
@Json(name = "j") val keywords: List<String> = emptyList(),
val mxcUrl: String = ""
val emoteImage: EmoteImage? = null,
) {
// Cannot be private...
var cache: String? = null
val mxcUrl: String = emoteImage?.url ?: ""
val emoji: String
get() {
cache?.let { return it }

View file

@ -182,12 +182,39 @@
android:src="@drawable/ic_attachment"
app:tint="?android:textColorHint"
app:layout_constraintBottom_toBottomOf="@id/sendButton"
app:layout_constraintEnd_toStartOf="@id/sendButton"
app:layout_constraintEnd_toStartOf="@id/sendStickerButton"
app:layout_constraintStart_toEndOf="@id/composerEditText"
app:layout_constraintTop_toTopOf="@id/sendButton"
app:layout_goneMarginBottom="57dp"
tools:ignore="MissingPrefix" />
<ImageButton
android:id="@+id/sendStickerButton"
android:layout_width="48dp"
android:layout_height="@dimen/composer_min_height"
android:contentDescription="@string/action_send_as_sticker"
android:scaleType="center"
android:src="@drawable/ic_send"
android:background="?android:attr/selectableItemBackground"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/sendButton"
app:layout_constraintStart_toEndOf="@id/attachmentButton"
app:layout_constraintTop_toTopOf="@id/sendButton"
app:layout_constraintBottom_toBottomOf="@id/sendButton"
tools:ignore="MissingPrefix"
tools:visibility="visible" />
<ImageView
android:id="@+id/sendStickerButtonDecor"
android:layout_width="20dp"
android:layout_height="20dp"
android:src="@drawable/ic_attachment_sticker"
android:visibility="gone"
app:tint="?colorAccent"
app:layout_constraintBottom_toBottomOf="@id/sendStickerButton"
app:layout_constraintEnd_toEndOf="@id/sendStickerButton"
tools:visibility="visible" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="48dp"
@ -199,7 +226,7 @@
android:background="?android:attr/selectableItemBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/attachmentButton"
app:layout_constraintStart_toEndOf="@id/sendStickerButton"
tools:ignore="MissingPrefix"
tools:visibility="visible" />