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" /> + + + +