From 5f787db4f1e8fceda72b1e01174b88cc482c22fb Mon Sep 17 00:00:00 2001 From: SpiritCroc Date: Sat, 1 Apr 2023 12:35:15 +0200 Subject: [PATCH] Send-as-sticker button for sticker-enabled custom emotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- FEATURES.md | 2 +- .../src/main/res/values/strings_sc.xml | 2 + .../matrix/android/sdk/api/util/MatrixItem.kt | 5 ++- .../session/room/send/pills/TextPillsUtils.kt | 22 ++++++++++ .../emoji/AutocompleteEmojiPresenter.kt | 2 +- .../home/room/detail/AutoCompleter.kt | 14 +++++-- .../home/room/detail/TimelineViewModel.kt | 20 +++++---- .../detail/composer/MessageComposerAction.kt | 1 + .../composer/MessageComposerFragment.kt | 41 ++++++++++++++++++- .../detail/composer/MessageComposerView.kt | 5 ++- .../composer/MessageComposerViewModel.kt | 1 + .../composer/PlainTextComposerLayout.kt | 23 ++++++++++- .../app/features/reactions/data/EmojiItem.kt | 5 ++- .../main/res/layout/composer_layout_sc.xml | 31 +++++++++++++- 14 files changed, 155 insertions(+), 19 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index aa6c117c58..61168608f6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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)) diff --git a/library/ui-strings/src/main/res/values/strings_sc.xml b/library/ui-strings/src/main/res/values/strings_sc.xml index a5da1759a0..cfc39ee8b6 100644 --- a/library/ui-strings/src/main/res/values/strings_sc.xml +++ b/library/ui-strings/src/main/res/values/strings_sc.xml @@ -227,4 +227,6 @@ Use the latest ${app_name_sc_stable} on your other devices: + Send as sticker + diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt index 41f7cde3a3..6746dba8dc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt @@ -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) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt index fa1840f520..a74ad62007 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt @@ -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 +} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/emoji/AutocompleteEmojiPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/emoji/AutocompleteEmojiPresenter.kt index ac094a9c2c..95d61a08cd 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/emoji/AutocompleteEmojiPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/emoji/AutocompleteEmojiPresenter.kt @@ -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 } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt index 17eeb1d48d..6c858cfcc5 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 25a62cc6d8..4751a2de85 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -676,13 +676,19 @@ class TimelineViewModel @AssistedInject constructor( private fun handleSendSticker(action: RoomDetailAction.SendSticker) { if (room == null) return val content = initialState.rootThreadEventId?.let { - action.stickerContent.copy( - relatesTo = RelationDefaultContent( - type = RelationType.THREAD, - isFallingBack = true, - eventId = it - ) - ) + // 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, + isFallingBack = true, + eventId = it + ) + ) + } else { + action.stickerContent + } } ?: action.stickerContent room.sendService().sendEvent(EventType.STICKER, content.toContent()) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 61bd7953f4..ba307b631a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 14970db365..89b402597d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -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(), 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(), 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)) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index 9174dc383c..4202756670 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index 7e30e68631..dcdcbed201 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -144,6 +144,7 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.SetFullScreen -> handleSetFullScreen(action) // SC MessageComposerAction.ClearFocus -> _viewEvents.post(MessageComposerViewEvents.ClearFocus) + MessageComposerAction.PopDraft -> popDraft(room) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt index e13b26c172..6ec504751f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/reactions/data/EmojiItem.kt b/vector/src/main/java/im/vector/app/features/reactions/data/EmojiItem.kt index 905c9cb73c..583b24c347 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/data/EmojiItem.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/data/EmojiItem.kt @@ -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 = 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 } diff --git a/vector/src/main/res/layout/composer_layout_sc.xml b/vector/src/main/res/layout/composer_layout_sc.xml index 6a12229155..6fed340a90 100644 --- a/vector/src/main/res/layout/composer_layout_sc.xml +++ b/vector/src/main/res/layout/composer_layout_sc.xml @@ -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" /> + + + +