diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt index 2c0e378efb..59d84ef40f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt @@ -66,11 +66,11 @@ interface RelationService { /** * Edit a text message body. Limited to "m.text" contentType - * @param targetEventId The event to edit + * @param targetEvent The event to edit * @param newBodyText The edited body * @param compatibilityBodyText The text that will appear on clients that don't support yet edition */ - fun editTextMessage(targetEventId: String, + fun editTextMessage(targetEvent: TimelineEvent, msgType: String, newBodyText: CharSequence, newBodyAutoMarkdown: Boolean, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index 152a018e78..6ae42de90c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -132,4 +132,9 @@ interface SendService { * Resend all failed messages one by one (and keep order) */ fun resendAllFailedMessages() + + /** + * Cancel all failed messages + */ + fun cancelAllFailedMessages() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt index 2876ec69e5..3c021384e1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineService.kt @@ -36,9 +36,23 @@ interface TimelineService { */ fun createTimeline(eventId: String?, settings: TimelineSettings): Timeline + /** + * Returns a snapshot of TimelineEvent event with eventId. + * At the opposite of getTimeLineEventLive which will be updated when local echo event is synced, it will return null in this case. + * @param eventId the eventId to get the TimelineEvent + */ fun getTimeLineEvent(eventId: String): TimelineEvent? + /** + * Creates a LiveData of Optional TimelineEvent event with eventId. + * If the eventId is a local echo eventId, it will make the LiveData be updated with the synced TimelineEvent when coming through the sync. + * In this case, makes sure to use the new synced eventId from the TimelineEvent class if you want to interact, as the local echo is removed from the SDK. + * @param eventId the eventId to listen for TimelineEvent + */ fun getTimeLineEventLive(eventId: String): LiveData> + /** + * Returns a snapshot list of TimelineEvent with EventType.MESSAGE and MessageType.MSGTYPE_IMAGE or MessageType.MSGTYPE_VIDEO. + */ fun getAttachmentMessages(): List } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt index 1fbc30d6f6..573f2c3a54 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt @@ -51,7 +51,6 @@ internal class DefaultSendEventTask @Inject constructor( val event = handleEncryption(params) val localId = event.eventId!! - localEchoRepository.updateSendState(localId, params.event.roomId, SendState.SENDING) val executeRequest = executeRequest(globalErrorReceiver) { apiCall = roomAPI.send( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt index da0cf45946..9693e56ff0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt @@ -17,14 +17,13 @@ package org.matrix.android.sdk.internal.session.room.relation import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import dagger.assisted.AssistedFactory import com.zhuinden.monarchy.Monarchy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary -import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.relation.RelationService import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Cancelable @@ -47,6 +46,7 @@ import timber.log.Timber internal class DefaultRelationService @AssistedInject constructor( @Assisted private val roomId: String, + private val eventEditor: EventEditor, private val eventSenderProcessor: EventSenderProcessor, private val eventFactory: LocalEchoEventFactory, private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, @@ -112,32 +112,19 @@ internal class DefaultRelationService @AssistedInject constructor( .executeBy(taskExecutor) } - override fun editTextMessage(targetEventId: String, + override fun editTextMessage(targetEvent: TimelineEvent, msgType: String, newBodyText: CharSequence, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String): Cancelable { - val event = eventFactory - .createReplaceTextEvent(roomId, targetEventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText) - .also { saveLocalEcho(it) } - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newBodyAutoMarkdown, compatibilityBodyText) } override fun editReply(replyToEdit: TimelineEvent, originalTimelineEvent: TimelineEvent, newBodyText: String, compatibilityBodyText: String): Cancelable { - val event = eventFactory.createReplaceTextOfReply( - roomId, - replyToEdit, - originalTimelineEvent, - newBodyText, - true, - MessageType.MSGTYPE_TEXT, - compatibilityBodyText - ) - .also { saveLocalEcho(it) } - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, compatibilityBodyText) } override suspend fun fetchEditHistory(eventId: String): List { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt new file mode 100644 index 0000000000..5fe06287d2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.relation + +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.api.util.NoOpCancellable +import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider +import org.matrix.android.sdk.internal.database.mapper.toEntity +import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory +import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository +import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor +import timber.log.Timber +import javax.inject.Inject + +internal class EventEditor @Inject constructor(private val eventSenderProcessor: EventSenderProcessor, + private val eventFactory: LocalEchoEventFactory, + private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, + private val localEchoRepository: LocalEchoRepository) { + + fun editTextMessage(targetEvent: TimelineEvent, + msgType: String, + newBodyText: CharSequence, + newBodyAutoMarkdown: Boolean, + compatibilityBodyText: String): Cancelable { + val roomId = targetEvent.roomId + if (targetEvent.root.sendState.hasFailed()) { + // We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event. + val editedEvent = eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown).copy( + eventId = targetEvent.eventId + ) + updateFailedEchoWithEvent(roomId, targetEvent.eventId, editedEvent) + return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + } else if (targetEvent.root.sendState.isSent()) { + val event = eventFactory + .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText) + .also { localEchoRepository.createLocalEcho(it) } + return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + } else { + // Should we throw? + Timber.w("Can't edit a sending event") + return NoOpCancellable + } + } + + fun editReply(replyToEdit: TimelineEvent, + originalTimelineEvent: TimelineEvent, + newBodyText: String, + compatibilityBodyText: String): Cancelable { + val roomId = replyToEdit.roomId + if (replyToEdit.root.sendState.hasFailed()) { + // We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event. + val editedEvent = eventFactory.createReplyTextEvent(roomId, originalTimelineEvent, newBodyText, false)?.copy( + eventId = replyToEdit.eventId + ) ?: return NoOpCancellable + updateFailedEchoWithEvent(roomId, replyToEdit.eventId, editedEvent) + return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + } else if (replyToEdit.root.sendState.isSent()) { + val event = eventFactory.createReplaceTextOfReply( + roomId, + replyToEdit, + originalTimelineEvent, + newBodyText, + true, + MessageType.MSGTYPE_TEXT, + compatibilityBodyText + ) + .also { localEchoRepository.createLocalEcho(it) } + return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + } else { + // Should we throw? + Timber.w("Can't edit a sending event") + return NoOpCancellable + } + } + + private fun updateFailedEchoWithEvent(roomId: String, failedEchoEventId: String, editedEvent: Event) { + val editedEventEntity = editedEvent.toEntity(roomId, SendState.UNSENT, System.currentTimeMillis()) + localEchoRepository.updateEchoAsync(failedEchoEventId) { _, entity -> + entity.content = editedEventEntity.content + entity.ageLocalTs = editedEventEntity.ageLocalTs + entity.age = editedEventEntity.age + entity.originServerTs = editedEventEntity.originServerTs + entity.sendState = editedEventEntity.sendState + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index a12962b51f..c5b8b42b3c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -232,6 +232,14 @@ internal class DefaultSendService @AssistedInject constructor( } } + override fun cancelAllFailedMessages() { + taskExecutor.executorScope.launch { + localEchoRepository.getAllFailedEventsToResend(roomId).forEach { event -> + cancelSend(event.eventId) + } + } + } + override fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, roomIds: Set): Cancelable { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt index ef890db79e..d000bbeb50 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt @@ -17,7 +17,6 @@ package org.matrix.android.sdk.internal.session.room.timeline import androidx.lifecycle.LiveData -import androidx.lifecycle.Transformations import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.assisted.AssistedFactory @@ -31,7 +30,6 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.util.Optional -import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.database.RealmSessionProvider import org.matrix.android.sdk.internal.database.mapper.ReadReceiptsSummaryMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper @@ -89,13 +87,7 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv } override fun getTimeLineEventLive(eventId: String): LiveData> { - val liveData = monarchy.findAllMappedWithChanges( - { TimelineEventEntity.where(it, roomId = roomId, eventId = eventId) }, - { timelineEventMapper.map(it) } - ) - return Transformations.map(liveData) { events -> - events.firstOrNull().toOptional() - } + return LiveTimelineEvent(timelineInput, monarchy, taskExecutor.executorScope, timelineEventMapper, roomId, eventId) } override fun getAttachmentMessages(): List { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LiveTimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LiveTimelineEvent.kt new file mode 100644 index 0000000000..3c0f101e11 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LiveTimelineEvent.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.timeline + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.Transformations +import com.zhuinden.monarchy.Monarchy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.events.model.LocalEcho +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.query.where +import java.util.concurrent.atomic.AtomicBoolean + +/** + * This class takes care of handling case where local echo is replaced by the synced event in the db. + */ +internal class LiveTimelineEvent(private val timelineInput: TimelineInput, + private val monarchy: Monarchy, + private val coroutineScope: CoroutineScope, + private val timelineEventMapper: TimelineEventMapper, + private val roomId: String, + private val eventId: String) + : TimelineInput.Listener, + MediatorLiveData>() { + + private var queryLiveData: LiveData>? = null + + // If we are listening to local echo, we want to be aware when event is synced + private var shouldObserveSync = AtomicBoolean(LocalEcho.isLocalEchoId(eventId)) + + init { + buildAndObserveQuery(eventId) + } + + // Makes sure it's made on the main thread + private fun buildAndObserveQuery(eventIdToObserve: String) = coroutineScope.launch(Dispatchers.Main) { + queryLiveData?.also { + removeSource(it) + } + val liveData = monarchy.findAllMappedWithChanges( + { TimelineEventEntity.where(it, roomId = roomId, eventId = eventIdToObserve) }, + { timelineEventMapper.map(it) } + ) + queryLiveData = Transformations.map(liveData) { events -> + events.firstOrNull().toOptional() + }.also { + addSource(it) { newValue -> value = newValue } + } + } + + override fun onLocalEchoSynced(roomId: String, localEchoEventId: String, syncedEventId: String) { + if (this.roomId == roomId && localEchoEventId == this.eventId) { + timelineInput.listeners.remove(this) + shouldObserveSync.set(false) + // rebuild the query with the new eventId + buildAndObserveQuery(syncedEventId) + } + } + + override fun onActive() { + super.onActive() + if (shouldObserveSync.get()) { + timelineInput.listeners.add(this) + } + } + + override fun onInactive() { + super.onInactive() + if (shouldObserveSync.get()) { + timelineInput.listeners.remove(this) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineInput.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineInput.kt index 002ab1dd8a..8911f265d5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineInput.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineInput.kt @@ -35,11 +35,16 @@ internal class TimelineInput @Inject constructor() { listeners.toSet().forEach { it.onNewTimelineEvents(roomId, eventIds) } } + fun onLocalEchoSynced(roomId: String, localEchoEventId: String, syncEventId: String) { + listeners.toSet().forEach { it.onLocalEchoSynced(roomId, localEchoEventId, syncEventId) } + } + val listeners = mutableSetOf() internal interface Listener { - fun onLocalEchoCreated(roomId: String, timelineEvent: TimelineEvent) - fun onLocalEchoUpdated(roomId: String, eventId: String, sendState: SendState) - fun onNewTimelineEvents(roomId: String, eventIds: List) + fun onLocalEchoCreated(roomId: String, timelineEvent: TimelineEvent) = Unit + fun onLocalEchoUpdated(roomId: String, eventId: String, sendState: SendState) = Unit + fun onNewTimelineEvents(roomId: String, eventIds: List) = Unit + fun onLocalEchoSynced(roomId: String, localEchoEventId: String, syncedEventId: String) = Unit } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt index b2db6320f1..336a83eaad 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt @@ -400,6 +400,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle event.mxDecryptionResult = adapter.fromJson(json) } } + timelineInput.onLocalEchoSynced(roomId, it, event.eventId) // Finally delete the local echo sendingEventEntity.deleteOnCascade(true) } else { diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 98c49d6549..468fe717c0 100644 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -161,7 +161,7 @@ Formatter\.formatShortFileSize===1 # android\.text\.TextUtils ### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt -enum class===91 +enum class===92 ### Do not import temporary legacy classes import org.matrix.android.sdk.internal.legacy.riot===3 diff --git a/vector/src/main/java/im/vector/app/core/ui/views/FailedMessagesWarningView.kt b/vector/src/main/java/im/vector/app/core/ui/views/FailedMessagesWarningView.kt new file mode 100644 index 0000000000..f9518552a3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/views/FailedMessagesWarningView.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.ui.views + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import im.vector.app.R +import im.vector.app.databinding.ViewFailedMessagesWarningBinding + +class FailedMessagesWarningView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + interface Callback { + fun onDeleteAllClicked() + fun onRetryClicked() + } + + var callback: Callback? = null + + private lateinit var views: ViewFailedMessagesWarningBinding + + init { + setupViews() + } + + private fun setupViews() { + inflate(context, R.layout.view_failed_messages_warning, this) + views = ViewFailedMessagesWarningBinding.bind(this) + + views.failedMessagesDeleteAllButton.setOnClickListener { callback?.onDeleteAllClicked() } + views.failedMessagesRetryButton.setOnClickListener { callback?.onRetryClicked() } + } + + fun render(hasFailedMessages: Boolean) { + isVisible = hasFailedMessages + } +} diff --git a/vector/src/main/java/im/vector/app/core/ui/views/SendStateImageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/SendStateImageView.kt new file mode 100644 index 0000000000..9acb82581f --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/views/SendStateImageView.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.ui.views + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.view.isVisible +import im.vector.app.R +import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration + +class SendStateImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatImageView(context, attrs, defStyleAttr) { + + init { + if (isInEditMode) { + render(SendStateDecoration.SENT) + } + } + + fun render(sendState: SendStateDecoration) { + isVisible = when (sendState) { + SendStateDecoration.SENDING_NON_MEDIA -> { + setImageResource(R.drawable.ic_sending_message) + contentDescription = context.getString(R.string.event_status_a11y_sending) + true + } + SendStateDecoration.SENT -> { + setImageResource(R.drawable.ic_message_sent) + contentDescription = context.getString(R.string.event_status_a11y_sent) + true + } + SendStateDecoration.FAILED -> { + setImageResource(R.drawable.ic_sending_message_failed) + contentDescription = context.getString(R.string.event_status_a11y_failed) + true + } + SendStateDecoration.SENDING_MEDIA, + SendStateDecoration.NONE -> { + false + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index efc495a379..ecbd0b0d30 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -106,4 +106,7 @@ sealed class RoomDetailAction : VectorViewModelAction { data class DoNotShowPreviewUrlFor(val eventId: String, val url: String) : RoomDetailAction() data class ComposerFocusChange(val focused: Boolean) : RoomDetailAction() + + // Failed messages + object RemoveAllFailedMessages : RoomDetailAction() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index c627a4a94b..1a487eb065 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -94,6 +94,7 @@ import im.vector.app.core.resources.ColorProvider import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.KnownCallsViewHolder import im.vector.app.core.ui.views.ActiveConferenceView +import im.vector.app.core.ui.views.FailedMessagesWarningView import im.vector.app.core.ui.views.JumpToReadMarkerView import im.vector.app.core.ui.views.NotificationAreaView import im.vector.app.core.utils.Debouncer @@ -325,6 +326,7 @@ class RoomDetailFragment @Inject constructor( setupJumpToBottomView() setupConfBannerView() setupEmojiPopup() + setupFailedMessagesWarningView() views.roomToolbarContentView.debouncedClicks { navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) @@ -557,6 +559,25 @@ class RoomDetailFragment @Inject constructor( } } + private fun setupFailedMessagesWarningView() { + views.failedMessagesWarningView.callback = object : FailedMessagesWarningView.Callback { + override fun onDeleteAllClicked() { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.event_status_delete_all_failed_dialog_title) + .setMessage(getString(R.string.event_status_delete_all_failed_dialog_message)) + .setNegativeButton(R.string.no, null) + .setPositiveButton(R.string.yes) { _, _ -> + roomDetailViewModel.handle(RoomDetailAction.RemoveAllFailedMessages) + } + .show() + } + + override fun onRetryClicked() { + roomDetailViewModel.handle(RoomDetailAction.ResendAll) + } + } + } + private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) { navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo)) } @@ -776,10 +797,6 @@ class RoomDetailFragment @Inject constructor( navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) true } - R.id.resend_all -> { - roomDetailViewModel.handle(RoomDetailAction.ResendAll) - true - } R.id.open_matrix_apps -> { roomDetailViewModel.handle(RoomDetailAction.ManageIntegrations) true @@ -1171,6 +1188,7 @@ class RoomDetailFragment @Inject constructor( val summary = state.asyncRoomSummary() renderToolbar(summary, state.typingMessage) views.activeConferenceView.render(state) + views.failedMessagesWarningView.render(state.hasFailedSending) val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { views.jumpToBottomView.count = summary.notificationCount @@ -1547,9 +1565,21 @@ class RoomDetailFragment @Inject constructor( MessageActionsBottomSheet .newInstance(roomId, informationData) .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") + return true } + private fun handleCancelSend(action: EventSharedAction.Cancel) { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.dialog_title_confirmation) + .setMessage(getString(R.string.event_status_cancel_sending_dialog_message)) + .setNegativeButton(R.string.no, null) + .setPositiveButton(R.string.yes) { _, _ -> + roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId)) + } + .show() + } + override fun onAvatarClicked(informationData: MessageInformationData) { // roomDetailViewModel.handle(RoomDetailAction.RequestVerification(informationData.userId)) openRoomMemberProfile(informationData.senderId) @@ -1745,7 +1775,7 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId)) } is EventSharedAction.Cancel -> { - roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId)) + handleCancelSend(action) } is EventSharedAction.ReportContentSpam -> { roomDetailViewModel.handle(RoomDetailAction.ReportContent( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index f293eadf50..115b0b29dd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -262,66 +262,68 @@ class RoomDetailViewModel @AssistedInject constructor( override fun handle(action: RoomDetailAction) { when (action) { - is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) - is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) - is RoomDetailAction.SaveDraft -> handleSaveDraft(action) - is RoomDetailAction.SendMessage -> handleSendMessage(action) - is RoomDetailAction.SendMedia -> handleSendMedia(action) - is RoomDetailAction.SendSticker -> handleSendSticker(action) - is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) - is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) - is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) - is RoomDetailAction.SendReaction -> handleSendReaction(action) - is RoomDetailAction.AcceptInvite -> handleAcceptInvite() - is RoomDetailAction.RejectInvite -> handleRejectInvite() - is RoomDetailAction.RedactAction -> handleRedactEvent(action) - is RoomDetailAction.UndoReaction -> handleUndoReact(action) - is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) - is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) - is RoomDetailAction.EnterEditMode -> handleEditAction(action) - is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) - is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) - is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) - is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) - is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) - is RoomDetailAction.ResendMessage -> handleResendEvent(action) - is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) - is RoomDetailAction.ResendAll -> handleResendAll() - is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() - is RoomDetailAction.ReportContent -> handleReportContent(action) - is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) + is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) + is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) + is RoomDetailAction.SaveDraft -> handleSaveDraft(action) + is RoomDetailAction.SendMessage -> handleSendMessage(action) + is RoomDetailAction.SendMedia -> handleSendMedia(action) + is RoomDetailAction.SendSticker -> handleSendSticker(action) + is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) + is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) + is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) + is RoomDetailAction.SendReaction -> handleSendReaction(action) + is RoomDetailAction.AcceptInvite -> handleAcceptInvite() + is RoomDetailAction.RejectInvite -> handleRejectInvite() + is RoomDetailAction.RedactAction -> handleRedactEvent(action) + is RoomDetailAction.UndoReaction -> handleUndoReact(action) + is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) + is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) + is RoomDetailAction.EnterEditMode -> handleEditAction(action) + is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) + is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) + is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) + is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) + is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) + is RoomDetailAction.ResendMessage -> handleResendEvent(action) + is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) + is RoomDetailAction.ResendAll -> handleResendAll() + is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() + is RoomDetailAction.ReportContent -> handleReportContent(action) + is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages() - is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() - is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) - is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) - is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) - is RoomDetailAction.RequestVerification -> handleRequestVerification(action) - is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) - is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) - is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) - is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() - is RoomDetailAction.StartCallWithPhoneNumber -> handleStartCallWithPhoneNumber(action) - is RoomDetailAction.StartCall -> handleStartCall(action) - is RoomDetailAction.AcceptCall -> handleAcceptCall(action) - is RoomDetailAction.EndCall -> handleEndCall() - is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() - is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) - is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) - is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) - is RoomDetailAction.CancelSend -> handleCancel(action) - is RoomDetailAction.OpenOrCreateDm -> handleOpenOrCreateDm(action) - is RoomDetailAction.JumpToReadReceipt -> handleJumpToReadReceipt(action) - RoomDetailAction.QuickActionInvitePeople -> handleInvitePeople() - RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() - is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) - RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) - is RoomDetailAction.ShowRoomAvatarFullScreen -> { + is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() + is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) + is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) + is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) + is RoomDetailAction.RequestVerification -> handleRequestVerification(action) + is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) + is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) + is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) + is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() + is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() + is RoomDetailAction.StartCallWithPhoneNumber -> handleStartCallWithPhoneNumber(action) + is RoomDetailAction.StartCall -> handleStartCall(action) + is RoomDetailAction.AcceptCall -> handleAcceptCall(action) + is RoomDetailAction.EndCall -> handleEndCall() + is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() + is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) + is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) + is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) + is RoomDetailAction.CancelSend -> handleCancel(action) + is RoomDetailAction.OpenOrCreateDm -> handleOpenOrCreateDm(action) + is RoomDetailAction.JumpToReadReceipt -> handleJumpToReadReceipt(action) + RoomDetailAction.QuickActionInvitePeople -> handleInvitePeople() + RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() + is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) + RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) + is RoomDetailAction.ShowRoomAvatarFullScreen -> { _viewEvents.post( RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView) ) } - is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action) + is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action) + RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages() + RoomDetailAction.ResendAll -> handleResendAll() }.exhaustive } @@ -660,10 +662,8 @@ class RoomDetailViewModel @AssistedInject constructor( return@withState false } when (itemId) { - R.id.resend_all -> state.asyncRoomSummary()?.hasFailedSending == true R.id.timeline_setting -> true - R.id.invite -> state.canInvite - R.id.clear_all -> state.asyncRoomSummary()?.hasFailedSending == true + R.id.invite -> state.canInvite R.id.open_matrix_apps -> true R.id.voice_call, R.id.video_call -> callManager.getCallsByRoomId(state.roomId).isEmpty() @@ -816,7 +816,7 @@ class RoomDetailViewModel @AssistedInject constructor( } }.exhaustive } - is SendMode.EDIT -> { + is SendMode.EDIT -> { // is original event a reply? val inReplyTo = state.sendMode.timelineEvent.getRelationContent()?.inReplyTo?.eventId if (inReplyTo != null) { @@ -828,7 +828,7 @@ class RoomDetailViewModel @AssistedInject constructor( val messageContent = state.sendMode.timelineEvent.getLastMessageContent() val existingBody = messageContent?.body ?: "" if (existingBody != action.text) { - room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "", + room.editTextMessage(state.sendMode.timelineEvent, messageContent?.msgType ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown) @@ -839,7 +839,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.MessageSent) popDraft() } - is SendMode.QUOTE -> { + is SendMode.QUOTE -> { val messageContent = state.sendMode.timelineEvent.getLastMessageContent() val textMsg = messageContent?.body @@ -860,7 +860,7 @@ class RoomDetailViewModel @AssistedInject constructor( _viewEvents.post(RoomDetailViewEvents.MessageSent) popDraft() } - is SendMode.REPLY -> { + is SendMode.REPLY -> { state.sendMode.timelineEvent.let { room.replyToMessage(it, action.text.toString(), action.autoMarkdown) _viewEvents.post(RoomDetailViewEvents.MessageSent) @@ -1223,6 +1223,10 @@ class RoomDetailViewModel @AssistedInject constructor( room.resendAllFailedMessages() } + private fun handleRemoveAllFailedMessages() { + room.cancelAllFailedMessages() + } + private fun observeEventDisplayedActions() { // We are buffering scroll events for one second // and keep the most recent one to set the read receipt on. @@ -1437,7 +1441,10 @@ class RoomDetailViewModel @AssistedInject constructor( roomSummariesHolder.set(summary) setState { val typingMessage = typingHelper.getTypingMessage(summary.typingUsers) - copy(typingMessage = typingMessage) + copy( + typingMessage = typingMessage, + hasFailedSending = summary.hasFailedSending + ) } if (summary.membership == Membership.INVITE) { summary.inviterId?.let { inviterId -> diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 8c2b3ffe98..965733c424 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -75,7 +75,8 @@ data class RoomDetailViewState( val canInvite: Boolean = true, val isAllowedToManageWidgets: Boolean = false, val isAllowedToStartWebRTCCall: Boolean = true, - val showDialerOption: Boolean = false + val showDialerOption: Boolean = false, + val hasFailedSending: Boolean = false ) : MvRxState { constructor(args: RoomDetailArgs) : this( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/MessageColorProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/MessageColorProvider.kt index d1bef4c8c7..2b39eb1e26 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/MessageColorProvider.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/MessageColorProvider.kt @@ -50,17 +50,8 @@ class MessageColorProvider @Inject constructor( SendState.FAILED_UNKNOWN_DEVICES -> colorProvider.getColorFromAttribute(R.attr.vctr_unsent_message_text_color) } } else { - // When not in developer mode, we do not use special color for the encrypting state - when (sendState) { - SendState.UNKNOWN, - SendState.UNSENT, - SendState.ENCRYPTING, - SendState.SENDING -> colorProvider.getColorFromAttribute(R.attr.vctr_sending_message_text_color) - SendState.SENT, - SendState.SYNCED -> colorProvider.getColorFromAttribute(R.attr.vctr_message_text_color) - SendState.UNDELIVERED, - SendState.FAILED_UNKNOWN_DEVICES -> colorProvider.getColorFromAttribute(R.attr.vctr_unsent_message_text_color) - } + // When not in developer mode, we use only one color + colorProvider.getColorFromAttribute(R.attr.vctr_message_text_color) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index d7295176fe..44f1e9b759 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -30,6 +30,7 @@ import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.epoxy.LoadingItem_ import im.vector.app.core.extensions.localDateTime import im.vector.app.core.extensions.nextOrNull +import im.vector.app.core.extensions.prevOrNull import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.RoomDetailViewState @@ -42,11 +43,13 @@ import im.vector.app.features.home.room.detail.timeline.helper.TimelineControlle import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider +import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData +import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.VideoContentRenderer @@ -336,11 +339,12 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec private fun buildCacheItem(currentPosition: Int, items: List): CacheItemData { val event = items[currentPosition] val nextEvent = items.nextOrNull(currentPosition) + val prevEvent = items.prevOrNull(currentPosition) if (hasReachedInvite && hasUTD) { return CacheItemData(event.localId, event.root.eventId, null, null, null) } updateUTDStates(event, nextEvent) - val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, callback).also { + val eventModel = timelineItemFactory.create(event, prevEvent, nextEvent, eventIdToHighlight, callback).also { it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } @@ -362,7 +366,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec requestModelBuild() } val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, event.root.originServerTs) - return CacheItemData(event.localId, event.root.eventId, eventModel, mergedHeaderModel, daySeparatorItem) + // If we have a SENT decoration, we want to built again as it might have to be changed to NONE if more recent event has also SENT decoration + val forceTriggerBuild = eventModel is AbsMessageItem && eventModel.attributes.informationData.sendStateDecoration == SendStateDecoration.SENT + return CacheItemData(event.localId, event.root.eventId, eventModel, mergedHeaderModel, daySeparatorItem, forceTriggerBuild) } private fun buildDaySeparatorItem(addDaySeparator: Boolean, originServerTs: Long?): DaySeparatorItem? { @@ -425,11 +431,12 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val eventId: String?, val eventModel: EpoxyModel<*>? = null, val mergedHeaderModel: BasedMergedItem<*>? = null, - val formattedDayModel: DaySeparatorItem? = null + val formattedDayModel: DaySeparatorItem? = null, + val forceTriggerBuild: Boolean = false ) { fun shouldTriggerBuild(): Boolean { // Since those items can change when we paginate, force a re-build - return mergedHeaderModel != null || formattedDayModel != null + return forceTriggerBuild || mergedHeaderModel != null || formattedDayModel != null } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt index 83d56a65c9..caee485aba 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt @@ -21,6 +21,7 @@ import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.Uninitialized import im.vector.app.core.extensions.canReact import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData +import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent /** @@ -56,4 +57,6 @@ data class MessageActionState( fun senderName(): String = informationData.memberName?.toString() ?: "" fun canReact() = timelineEvent()?.canReact() == true && actionPermissions.canReact + + fun sendState(): SendState? = timelineEvent()?.root?.sendState } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt index cc3d5cc463..1e93c29673 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt @@ -34,6 +34,8 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.room.send.SendState import javax.inject.Inject /** @@ -63,23 +65,24 @@ class MessageActionsEpoxyController @Inject constructor( } // Send state - if (state.informationData.sendState.isSending()) { - bottomSheetSendStateItem { - id("send_state") - showProgress(true) - text(stringProvider.getString(R.string.event_status_sending_message)) - } - } else if (state.informationData.sendState.hasFailed()) { + val sendState = state.sendState() + if (sendState?.hasFailed().orFalse()) { bottomSheetSendStateItem { id("send_state") showProgress(false) text(stringProvider.getString(R.string.unable_to_send_message)) drawableStart(R.drawable.ic_warning_badge) } + } else if (sendState != SendState.SYNCED) { + bottomSheetSendStateItem { + id("send_state") + showProgress(true) + text(stringProvider.getString(R.string.event_status_sending_message)) + } } when (state.informationData.e2eDecoration) { - E2EDecoration.WARN_IN_CLEAR -> { + E2EDecoration.WARN_IN_CLEAR -> { bottomSheetSendStateItem { id("e2e_clear") showProgress(false) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 363899c4f9..adf315a955 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -18,10 +18,11 @@ package im.vector.app.features.home.room.detail.timeline.action import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.ViewModelContext -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import dagger.assisted.AssistedFactory +import com.jakewharton.rxrelay2.BehaviorRelay import dagger.Lazy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import im.vector.app.R import im.vector.app.core.extensions.canReact import im.vector.app.core.platform.EmptyViewEvents @@ -69,13 +70,14 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted private val vectorPreferences: VectorPreferences ) : VectorViewModel(initialState) { - private val eventId = initialState.eventId private val informationData = initialState.informationData private val room = session.getRoom(initialState.roomId) private val pillsPostProcessor by lazy { pillsPostProcessorFactory.create(initialState.roomId) } + private val eventIdObservable = BehaviorRelay.createDefault(initialState.eventId) + @AssistedFactory interface Factory { fun create(initialState: MessageActionState): MessageActionsViewModel @@ -130,7 +132,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted private fun observeEvent() { if (room == null) return room.rx() - .liveTimelineEvent(eventId) + .liveTimelineEvent(initialState.eventId) .unwrap() .execute { copy(timelineEvent = it) @@ -139,12 +141,15 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted private fun observeReactions() { if (room == null) return - room.rx() - .liveAnnotationSummary(eventId) - .map { annotations -> - EmojiDataSource.quickEmojis.map { emoji -> - ToggleState(emoji, annotations.getOrNull()?.reactionsSummary?.firstOrNull { it.key == emoji }?.addedByMe ?: false) - } + eventIdObservable + .switchMap { eventId -> + room.rx() + .liveAnnotationSummary(eventId) + .map { annotations -> + EmojiDataSource.quickEmojis.map { emoji -> + ToggleState(emoji, annotations.getOrNull()?.reactionsSummary?.firstOrNull { it.key == emoji }?.addedByMe ?: false) + } + } } .execute { copy(quickStates = it) @@ -154,8 +159,10 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted private fun observeTimelineEventState() { selectSubscribe(MessageActionState::timelineEvent, MessageActionState::actionPermissions) { timelineEvent, permissions -> val nonNullTimelineEvent = timelineEvent() ?: return@selectSubscribe + eventIdObservable.accept(nonNullTimelineEvent.eventId) setState { copy( + eventId = nonNullTimelineEvent.eventId, messageBody = computeMessageBody(nonNullTimelineEvent), actions = actionsForEvent(nonNullTimelineEvent, permissions) ) @@ -233,94 +240,15 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted val msgType = messageContent?.msgType return arrayListOf().apply { - if (timelineEvent.root.sendState.hasFailed()) { - if (canRetry(timelineEvent, actionPermissions)) { - add(EventSharedAction.Resend(eventId)) + when { + timelineEvent.root.sendState.hasFailed() -> { + addActionsForFailedState(timelineEvent, actionPermissions, messageContent, msgType) } - add(EventSharedAction.Remove(eventId)) - if (vectorPreferences.developerMode()) { - addViewSourceItems(timelineEvent) + timelineEvent.root.sendState.isSending() -> { + addActionsForSendingState(timelineEvent) } - } else if (timelineEvent.root.sendState.isSending()) { - // TODO is uploading attachment? - if (canCancel(timelineEvent)) { - add(EventSharedAction.Cancel(eventId)) - } - } else if (timelineEvent.root.sendState == SendState.SYNCED) { - if (!timelineEvent.root.isRedacted()) { - if (canReply(timelineEvent, messageContent, actionPermissions)) { - add(EventSharedAction.Reply(eventId)) - } - - if (canEdit(timelineEvent, session.myUserId, actionPermissions)) { - add(EventSharedAction.Edit(eventId)) - } - - if (canRedact(timelineEvent, actionPermissions)) { - add(EventSharedAction.Redact(eventId, askForReason = informationData.senderId != session.myUserId)) - } - - if (canCopy(msgType)) { - // TODO copy images? html? see ClipBoard - add(EventSharedAction.Copy(messageContent!!.body)) - } - - if (timelineEvent.canReact() && actionPermissions.canReact) { - add(EventSharedAction.AddReaction(eventId)) - } - - if (canQuote(timelineEvent, messageContent, actionPermissions)) { - add(EventSharedAction.Quote(eventId)) - } - - if (canViewReactions(timelineEvent)) { - add(EventSharedAction.ViewReactions(informationData)) - } - - if (timelineEvent.hasBeenEdited()) { - add(EventSharedAction.ViewEditHistory(informationData)) - } - - if (canShare(msgType)) { - add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!)) - } - - if (canSave(msgType) && messageContent is MessageWithAttachmentContent) { - add(EventSharedAction.Save(timelineEvent.eventId, messageContent)) - } - - if (timelineEvent.root.sendState == SendState.SENT) { - // TODO Can be redacted - - // TODO sent by me or sufficient power level - } - } - - if (vectorPreferences.developerMode()) { - if (timelineEvent.isEncrypted() && timelineEvent.root.mCryptoError != null) { - val keysBackupService = session.cryptoService().keysBackupService() - if (keysBackupService.state == KeysBackupState.NotTrusted - || (keysBackupService.state == KeysBackupState.ReadyToBackUp - && keysBackupService.canRestoreKeys()) - ) { - add(EventSharedAction.UseKeyBackup) - } - if (session.cryptoService().getCryptoDeviceInfo(session.myUserId).size > 1 - || timelineEvent.senderInfo.userId != session.myUserId) { - add(EventSharedAction.ReRequestKey(timelineEvent.eventId)) - } - } - addViewSourceItems(timelineEvent) - } - add(EventSharedAction.CopyPermalink(eventId)) - if (session.myUserId != timelineEvent.root.senderId) { - // not sent by me - if (timelineEvent.root.getClearType() == EventType.MESSAGE) { - add(EventSharedAction.ReportContent(eventId, timelineEvent.root.senderId)) - } - - add(EventSharedAction.Separator) - add(EventSharedAction.IgnoreUser(timelineEvent.root.senderId)) + timelineEvent.root.sendState == SendState.SYNCED -> { + addActionsForSyncedState(timelineEvent, actionPermissions, messageContent, msgType) } } } @@ -335,6 +263,116 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } } + private fun ArrayList.addActionsForFailedState(timelineEvent: TimelineEvent, + actionPermissions: ActionPermissions, + messageContent: MessageContent?, + msgType: String?) { + val eventId = timelineEvent.eventId + if (canRetry(timelineEvent, actionPermissions)) { + add(EventSharedAction.Resend(eventId)) + } + add(EventSharedAction.Remove(eventId)) + if (canEdit(timelineEvent, session.myUserId, actionPermissions)) { + add(EventSharedAction.Edit(eventId)) + } + if (canCopy(msgType)) { + // TODO copy images? html? see ClipBoard + add(EventSharedAction.Copy(messageContent!!.body)) + } + if (vectorPreferences.developerMode()) { + addViewSourceItems(timelineEvent) + } + } + + private fun ArrayList.addActionsForSendingState(timelineEvent: TimelineEvent) { + // TODO is uploading attachment? + if (canCancel(timelineEvent)) { + add(EventSharedAction.Cancel(timelineEvent.eventId)) + } + } + + private fun ArrayList.addActionsForSyncedState(timelineEvent: TimelineEvent, + actionPermissions: ActionPermissions, + messageContent: MessageContent?, + msgType: String?) { + val eventId = timelineEvent.eventId + if (!timelineEvent.root.isRedacted()) { + if (canReply(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.Reply(eventId)) + } + + if (canEdit(timelineEvent, session.myUserId, actionPermissions)) { + add(EventSharedAction.Edit(eventId)) + } + + if (canRedact(timelineEvent, actionPermissions)) { + add(EventSharedAction.Redact(eventId, askForReason = informationData.senderId != session.myUserId)) + } + + if (canCopy(msgType)) { + // TODO copy images? html? see ClipBoard + add(EventSharedAction.Copy(messageContent!!.body)) + } + + if (timelineEvent.canReact() && actionPermissions.canReact) { + add(EventSharedAction.AddReaction(eventId)) + } + + if (canQuote(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.Quote(eventId)) + } + + if (canViewReactions(timelineEvent)) { + add(EventSharedAction.ViewReactions(informationData)) + } + + if (timelineEvent.hasBeenEdited()) { + add(EventSharedAction.ViewEditHistory(informationData)) + } + + if (canShare(msgType)) { + add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!)) + } + + if (canSave(msgType) && messageContent is MessageWithAttachmentContent) { + add(EventSharedAction.Save(timelineEvent.eventId, messageContent)) + } + + if (timelineEvent.root.sendState == SendState.SENT) { + // TODO Can be redacted + + // TODO sent by me or sufficient power level + } + } + + if (vectorPreferences.developerMode()) { + if (timelineEvent.isEncrypted() && timelineEvent.root.mCryptoError != null) { + val keysBackupService = session.cryptoService().keysBackupService() + if (keysBackupService.state == KeysBackupState.NotTrusted + || (keysBackupService.state == KeysBackupState.ReadyToBackUp + && keysBackupService.canRestoreKeys()) + ) { + add(EventSharedAction.UseKeyBackup) + } + if (session.cryptoService().getCryptoDeviceInfo(session.myUserId).size > 1 + || timelineEvent.senderInfo.userId != session.myUserId) { + add(EventSharedAction.ReRequestKey(timelineEvent.eventId)) + } + } + addViewSourceItems(timelineEvent) + } + add(EventSharedAction.CopyPermalink(eventId)) + if (session.myUserId != timelineEvent.root.senderId) { + // not sent by me + if (timelineEvent.root.getClearType() == EventType.MESSAGE) { + add(EventSharedAction.ReportContent(eventId, timelineEvent.root.senderId)) + } + + add(EventSharedAction.Separator) + add(EventSharedAction.IgnoreUser(timelineEvent.root.senderId)) + } + } + private fun canCancel(@Suppress("UNUSED_PARAMETER") event: TimelineEvent): Boolean { return true } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt index d3dd94eae7..548f7a3b1c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -52,7 +52,7 @@ class CallItemFactory @Inject constructor( ): VectorEpoxyModel<*>? { if (event.root.eventId == null) return null val roomId = event.roomId - val informationData = messageInformationDataFactory.create(event, null) + val informationData = messageInformationDataFactory.create(event, null, null) val callSignalingContent = event.getCallSignallingContent() ?: return null val callId = callSignalingContent.callId ?: return null val call = callManager.getCallById(callId) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/DefaultItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/DefaultItemFactory.kt index 9d82103d3b..71ac46307b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/DefaultItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/DefaultItemFactory.kt @@ -61,7 +61,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava } else { stringProvider.getString(R.string.rendering_event_error_exception, event.root.eventId) } - val informationData = informationDataFactory.create(event, null) + val informationData = informationDataFactory.create(event, null, null) return create(text, informationData, highlight, callback) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index e88c1f3797..b531e08359 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -47,6 +47,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat private val vectorPreferences: VectorPreferences) { fun create(event: TimelineEvent, + prevEvent: TimelineEvent?, nextEvent: TimelineEvent?, highlight: Boolean, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { @@ -108,7 +109,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat } } - val informationData = messageInformationDataFactory.create(event, nextEvent) + val informationData = messageInformationDataFactory.create(event, prevEvent, nextEvent) val attributes = attributesFactory.create(event.root.content.toModel(), informationData, callback) return MessageTextItem_() .leftGuideline(avatarSizeProvider.leftGuideline) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt index 1eb09f2e7a..68716a3eba 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt @@ -48,7 +48,7 @@ class EncryptionItemFactory @Inject constructor( return null } val algorithm = event.root.getClearContent().toModel()?.algorithm - val informationData = informationDataFactory.create(event, null) + val informationData = informationDataFactory.create(event, null, null) val attributes = messageItemAttributesFactory.create(null, informationData, callback) val isSafeAlgorithm = algorithm == MXCRYPTO_ALGORITHM_MEGOLM diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 2e97abc32e..e969998613 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -119,13 +119,14 @@ class MessageItemFactory @Inject constructor( } fun create(event: TimelineEvent, + prevEvent: TimelineEvent?, nextEvent: TimelineEvent?, highlight: Boolean, callback: TimelineEventController.Callback? ): VectorEpoxyModel<*>? { event.root.eventId ?: return null roomId = event.roomId - val informationData = messageInformationDataFactory.create(event, nextEvent) + val informationData = messageInformationDataFactory.create(event, prevEvent, nextEvent) if (event.root.isRedacted()) { // message is redacted val attributes = messageItemAttributesFactory.create(null, informationData, callback) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index 12c7c2318a..dfabf96199 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -35,7 +35,7 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv highlight: Boolean, callback: TimelineEventController.Callback?): NoticeItem? { val formattedText = eventFormatter.format(event) ?: return null - val informationData = informationDataFactory.create(event, null) + val informationData = informationDataFactory.create(event, null, null) val attributes = NoticeItem.Attributes( avatarRenderer = avatarRenderer, informationData = informationData, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 7fd50147d4..ccc8289e08 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -37,7 +37,11 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me private val callItemFactory: CallItemFactory, private val userPreferencesProvider: UserPreferencesProvider) { + /** + * Reminder: nextEvent is older and prevEvent is newer. + */ fun create(event: TimelineEvent, + prevEvent: TimelineEvent?, nextEvent: TimelineEvent?, eventIdToHighlight: String?, callback: TimelineEventController.Callback?): VectorEpoxyModel<*> { @@ -46,7 +50,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me val computedModel = try { when (event.root.getClearType()) { EventType.STICKER, - EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, callback) + EventType.MESSAGE -> messageItemFactory.create(event, prevEvent, nextEvent, highlight, callback) // State and call EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_NAME, @@ -76,9 +80,9 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.ENCRYPTED -> { if (event.root.isRedacted()) { // Redacted event, let the MessageItemFactory handle it - messageItemFactory.create(event, nextEvent, highlight, callback) + messageItemFactory.create(event, prevEvent, nextEvent, highlight, callback) } else { - encryptedItemFactory.create(event, nextEvent, highlight, callback) + encryptedItemFactory.create(event, prevEvent, nextEvent, highlight, callback) } } EventType.STATE_ROOM_ALIASES, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt index eb539d2b8a..960487140d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VerificationItemFactory.kt @@ -75,9 +75,9 @@ class VerificationItemFactory @Inject constructor( // If it's not a request ignore this event // if (refEvent.root.getClearContent().toModel() == null) return ignoredConclusion(event, highlight, callback) - val referenceInformationData = messageInformationDataFactory.create(refEvent, null) + val referenceInformationData = messageInformationDataFactory.create(refEvent, null, null) - val informationData = messageInformationDataFactory.create(event, null) + val informationData = messageInformationDataFactory.create(event, null, null) val attributes = messageItemAttributesFactory.create(null, informationData, callback) when (event.root.getClearType()) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt index 260958b19e..a6a88a3444 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt @@ -64,7 +64,7 @@ class WidgetItemFactory @Inject constructor( callback: TimelineEventController.Callback?, widgetContent: WidgetContent, previousWidgetContent: WidgetContent?): VectorEpoxyModel<*> { - val informationData = informationDataFactory.create(timelineEvent, null) + val informationData = informationDataFactory.create(timelineEvent, null, null) val attributes = messageItemAttributesFactory.create(null, informationData, callback) val disambiguatedDisplayName = timelineEvent.senderInfo.disambiguatedDisplayName diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 951a4d3fa0..09f173de14 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -25,11 +25,13 @@ import im.vector.app.features.home.room.detail.timeline.item.PollResponseData import im.vector.app.features.home.room.detail.timeline.item.ReactionInfoData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReferencesInfoData +import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.crypto.VerificationState import org.matrix.android.sdk.api.extensions.orFalse 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.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent @@ -49,7 +51,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses private val dateFormatter: VectorDateFormatter, private val vectorPreferences: VectorPreferences) { - fun create(event: TimelineEvent, nextEvent: TimelineEvent?): MessageInformationData { + fun create(event: TimelineEvent, prevEvent: TimelineEvent?, nextEvent: TimelineEvent?): MessageInformationData { // Non nullability has been tested before val eventId = event.root.eventId!! @@ -70,6 +72,19 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses val time = dateFormatter.format(event.root.originServerTs, DateFormatKind.MESSAGE_SIMPLE) val e2eDecoration = getE2EDecoration(event) + // SendState Decoration + val isSentByMe = event.root.senderId == session.myUserId + val sendStateDecoration = if (isSentByMe) { + getSendStateDecoration( + eventSendState = event.root.sendState, + prevEventSendState = prevEvent?.root?.sendState, + anyReadReceipts = event.readReceipts.any { it.user.userId != session.myUserId }, + isMedia = event.root.isAttachmentMessage() + ) + } else { + SendStateDecoration.NONE + } + return MessageInformationData( eventId = eventId, senderId = event.root.senderId ?: "", @@ -110,11 +125,27 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses ?: VerificationState.REQUEST ReferencesInfoData(verificationState) }, - sentByMe = event.root.senderId == session.myUserId, - e2eDecoration = e2eDecoration + sentByMe = isSentByMe, + e2eDecoration = e2eDecoration, + sendStateDecoration = sendStateDecoration ) } + private fun getSendStateDecoration(eventSendState: SendState, + prevEventSendState: SendState?, + anyReadReceipts: Boolean, + isMedia: Boolean): SendStateDecoration { + return if (eventSendState.isSending()) { + if (isMedia) SendStateDecoration.SENDING_MEDIA else SendStateDecoration.SENDING_NON_MEDIA + } else if (eventSendState.hasFailed()) { + SendStateDecoration.FAILED + } else if (eventSendState.isSent() && !prevEventSendState?.isSent().orFalse() && !anyReadReceipts) { + SendStateDecoration.SENT + } else { + SendStateDecoration.NONE + } + } + private fun getE2EDecoration(event: TimelineEvent): E2EDecoration { val roomSummary = roomSummariesHolder.get(event.roomId) return if ( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index d4b1b8859a..ed61abcf6e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -19,19 +19,21 @@ package im.vector.app.features.home.room.detail.timeline.item import android.graphics.Typeface import android.view.View import android.widget.ImageView +import android.widget.ProgressBar import android.widget.TextView import androidx.annotation.IdRes import androidx.core.view.isInvisible import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import im.vector.app.R +import im.vector.app.core.ui.views.SendStateImageView import im.vector.app.core.utils.DebouncedClickListener import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController /** - * Base timeline item that adds an optional information bar with the sender avatar, name and time + * Base timeline item that adds an optional information bar with the sender avatar, name, time, send state * Adds associated click listeners (on avatar, displayname) */ abstract class AbsMessageItem : AbsBaseMessageItem() { @@ -82,6 +84,10 @@ abstract class AbsMessageItem : AbsBaseMessageItem holder.avatarImageView.setOnLongClickListener(null) holder.memberNameView.setOnLongClickListener(null) } + + // Render send state indicator + holder.sendStateImageView.render(attributes.informationData.sendStateDecoration) + holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA } override fun unbind(holder: H) { @@ -99,6 +105,8 @@ abstract class AbsMessageItem : AbsBaseMessageItem val avatarImageView by bind(R.id.messageAvatarImageView) val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) + val sendStateImageView by bind(R.id.messageSendStateImageView) + val eventSendingIndicator by bind(R.id.eventSendingIndicator) } /** diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt index 5160a7cd84..b215fa5dd5 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt @@ -28,7 +28,6 @@ import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder -import org.matrix.android.sdk.api.session.room.send.SendState @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageFileItem : AbsMessageItem() { @@ -87,13 +86,6 @@ abstract class MessageFileItem : AbsMessageItem() { holder.fileImageWrapper.setOnClickListener(attributes.itemClickListener) holder.fileImageWrapper.setOnLongClickListener(attributes.itemLongClickListener) holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG) - - holder.eventSendingIndicator.isVisible = when (attributes.informationData.sendState) { - SendState.UNSENT, - SendState.ENCRYPTING, - SendState.SENDING -> true - else -> false - } } override fun unbind(holder: Holder) { @@ -111,7 +103,6 @@ abstract class MessageFileItem : AbsMessageItem() { val fileImageWrapper by bind(R.id.messageFileImageView) val fileDownloadProgress by bind(R.id.messageFileProgressbar) val filenameView by bind(R.id.messageFilenameView) - val eventSendingIndicator by bind(R.id.eventSendingIndicator) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt index b121b14b4a..2c0d1fcfbd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt @@ -19,7 +19,6 @@ package im.vector.app.features.home.room.detail.timeline.item import android.view.View import android.view.ViewGroup import android.widget.ImageView -import android.widget.ProgressBar import androidx.core.view.ViewCompat import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute @@ -29,7 +28,6 @@ import im.vector.app.core.files.LocalFilesHelper import im.vector.app.core.glide.GlideApp import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.app.features.media.ImageContentRenderer -import org.matrix.android.sdk.api.session.room.send.SendState @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageImageVideoItem : AbsMessageItem() { @@ -69,16 +67,7 @@ abstract class MessageImageVideoItem : AbsMessageItem true - else -> false - } } override fun unbind(holder: Holder) { @@ -96,10 +85,7 @@ abstract class MessageImageVideoItem : AbsMessageItem(R.id.messageMediaUploadProgressLayout) val imageView by bind(R.id.messageThumbnailView) val playContentView by bind(R.id.messageMediaPlayView) - val mediaContentView by bind(R.id.messageContentMedia) - val failedToSendIndicator by bind(R.id.messageFailToSendIndicator) - val eventSendingIndicator by bind(R.id.eventSendingIndicator) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt index 48bd4db94c..67b79bab9b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -42,7 +42,8 @@ data class MessageInformationData( val readReceipts: List = emptyList(), val referencesInfoData: ReferencesInfoData? = null, val sentByMe: Boolean, - val e2eDecoration: E2EDecoration = E2EDecoration.NONE + val e2eDecoration: E2EDecoration = E2EDecoration.NONE, + val sendStateDecoration: SendStateDecoration = SendStateDecoration.NONE ) : Parcelable { val matrixItem: MatrixItem @@ -84,4 +85,12 @@ enum class E2EDecoration { WARN_SENT_BY_UNKNOWN } +enum class SendStateDecoration { + NONE, + SENDING_NON_MEDIA, + SENDING_MEDIA, + SENT, + FAILED +} + fun ReadReceiptData.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl) diff --git a/vector/src/main/res/drawable/ic_delete_unsent_messages.xml b/vector/src/main/res/drawable/ic_delete_unsent_messages.xml new file mode 100644 index 0000000000..24fdbc94c2 --- /dev/null +++ b/vector/src/main/res/drawable/ic_delete_unsent_messages.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_message_sent.xml b/vector/src/main/res/drawable/ic_message_sent.xml new file mode 100644 index 0000000000..3729f3d60f --- /dev/null +++ b/vector/src/main/res/drawable/ic_message_sent.xml @@ -0,0 +1,13 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_retry_sending_messages.xml b/vector/src/main/res/drawable/ic_retry_sending_messages.xml new file mode 100644 index 0000000000..6ea08bb654 --- /dev/null +++ b/vector/src/main/res/drawable/ic_retry_sending_messages.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_sending_message.xml b/vector/src/main/res/drawable/ic_sending_message.xml new file mode 100644 index 0000000000..05fa0fb2a2 --- /dev/null +++ b/vector/src/main/res/drawable/ic_sending_message.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_sending_message_failed.xml b/vector/src/main/res/drawable/ic_sending_message_failed.xml new file mode 100644 index 0000000000..c720a1cbbf --- /dev/null +++ b/vector/src/main/res/drawable/ic_sending_message_failed.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index df36d7e225..11d97c9f4b 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -86,7 +86,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" app:barrierDirection="top" - app:constraint_referenced_ids="composerLayout,notificationAreaView" /> + app:constraint_referenced_ids="composerLayout,notificationAreaView,failedMessagesWarningView" /> + + + app:constraint_referenced_ids="composerLayout,notificationAreaView, failedMessagesWarningView" /> + + - - diff --git a/vector/src/main/res/layout/item_timeline_event_base.xml b/vector/src/main/res/layout/item_timeline_event_base.xml index cba12f7515..ce3460a21c 100644 --- a/vector/src/main/res/layout/item_timeline_event_base.xml +++ b/vector/src/main/res/layout/item_timeline_event_base.xml @@ -80,6 +80,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@id/messageMemberNameView" + android:layout_toStartOf="@id/messageSendStateImageView" android:layout_toEndOf="@id/messageStartGuideline" android:addStatesFromChildren="true"> @@ -133,6 +134,33 @@ + + + + - - - - - - + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/view_read_receipts.xml b/vector/src/main/res/layout/view_read_receipts.xml index 3e4c49fe00..ac4351b379 100644 --- a/vector/src/main/res/layout/view_read_receipts.xml +++ b/vector/src/main/res/layout/view_read_receipts.xml @@ -9,7 +9,7 @@ - - - - 196dp 44dp 72dp + 16dp 40dp 60dp diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 297be6faca..36ee7898e5 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -2051,7 +2051,7 @@ Edit Reply - "Retry" + Retry "Join a room to start using the app." "Sent you an invitation" Invited by %s @@ -3239,4 +3239,13 @@ Event sent! State event sent! Event content + + Sending + Sent + Failed + Delete all failed messages + Do you want to cancel sending message? + Messages failed to send + Delete unsent messages + Are you sure you want to delete all unsent messages in this room?