Merge pull request #2937 from vector-im/feature/ons/message_states

Improve the status of send messages
This commit is contained in:
Benoit Marty 2021-03-10 21:51:48 +01:00 committed by GitHub
commit 8a1a90d1b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 898 additions and 345 deletions

View File

@ -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,

View File

@ -132,4 +132,9 @@ interface SendService {
* Resend all failed messages one by one (and keep order)
*/
fun resendAllFailedMessages()
/**
* Cancel all failed messages
*/
fun cancelAllFailedMessages()
}

View File

@ -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<Optional<TimelineEvent>>
/**
* Returns a snapshot list of TimelineEvent with EventType.MESSAGE and MessageType.MSGTYPE_IMAGE or MessageType.MSGTYPE_VIDEO.
*/
fun getAttachmentMessages(): List<TimelineEvent>
}

View File

@ -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<SendResponse>(globalErrorReceiver) {
apiCall = roomAPI.send(

View File

@ -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<Event> {

View File

@ -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
}
}
}

View File

@ -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<String>): Cancelable {

View File

@ -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<Optional<TimelineEvent>> {
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<TimelineEvent> {

View File

@ -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<Optional<TimelineEvent>>() {
private var queryLiveData: LiveData<Optional<TimelineEvent>>? = 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)
}
}
}

View File

@ -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<Listener>()
internal interface Listener {
fun onLocalEchoCreated(roomId: String, timelineEvent: TimelineEvent)
fun onLocalEchoUpdated(roomId: String, eventId: String, sendState: SendState)
fun onNewTimelineEvents(roomId: String, eventIds: List<String>)
fun onLocalEchoCreated(roomId: String, timelineEvent: TimelineEvent) = Unit
fun onLocalEchoUpdated(roomId: String, eventId: String, sendState: SendState) = Unit
fun onNewTimelineEvents(roomId: String, eventIds: List<String>) = Unit
fun onLocalEchoSynced(roomId: String, localEchoEventId: String, syncedEventId: String) = Unit
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}
}
}

View File

@ -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()
}

View File

@ -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(

View File

@ -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 ->

View File

@ -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(

View File

@ -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)
}
}
}

View File

@ -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<TimelineEvent>): 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
}
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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<MessageActionState, MessageActionsAction, EmptyViewEvents>(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<EventSharedAction>().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<EventSharedAction>.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<EventSharedAction>.addActionsForSendingState(timelineEvent: TimelineEvent) {
// TODO is uploading attachment?
if (canCancel(timelineEvent)) {
add(EventSharedAction.Cancel(timelineEvent.eventId))
}
}
private fun ArrayList<EventSharedAction>.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
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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<EncryptedEventContent>(), informationData, callback)
return MessageTextItem_()
.leftGuideline(avatarSizeProvider.leftGuideline)

View File

@ -48,7 +48,7 @@ class EncryptionItemFactory @Inject constructor(
return null
}
val algorithm = event.root.getClearContent().toModel<EncryptionEventContent>()?.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

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -75,9 +75,9 @@ class VerificationItemFactory @Inject constructor(
// If it's not a request ignore this event
// if (refEvent.root.getClearContent().toModel<MessageVerificationRequestContent>() == 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()) {

View File

@ -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

View File

@ -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 (

View File

@ -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<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>() {
@ -82,6 +84,10 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
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<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView)
val sendStateImageView by bind<SendStateImageView>(R.id.messageSendStateImageView)
val eventSendingIndicator by bind<ProgressBar>(R.id.eventSendingIndicator)
}
/**

View File

@ -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<MessageFileItem.Holder>() {
@ -87,13 +86,6 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
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<MessageFileItem.Holder>() {
val fileImageWrapper by bind<ViewGroup>(R.id.messageFileImageView)
val fileDownloadProgress by bind<ProgressBar>(R.id.messageFileProgressbar)
val filenameView by bind<TextView>(R.id.messageFilenameView)
val eventSendingIndicator by bind<ProgressBar>(R.id.eventSendingIndicator)
}
companion object {

View File

@ -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<MessageImageVideoItem.Holder>() {
@ -69,16 +67,7 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
ViewCompat.setTransitionName(holder.imageView, "imagePreview_${id()}")
holder.mediaContentView.setOnClickListener(attributes.itemClickListener)
holder.mediaContentView.setOnLongClickListener(attributes.itemLongClickListener)
// The sending state color will be apply to the progress text
renderSendState(holder.imageView, null, holder.failedToSendIndicator)
holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
holder.eventSendingIndicator.isVisible = when (attributes.informationData.sendState) {
SendState.UNSENT,
SendState.ENCRYPTING,
SendState.SENDING -> true
else -> false
}
}
override fun unbind(holder: Holder) {
@ -96,10 +85,7 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
val progressLayout by bind<ViewGroup>(R.id.messageMediaUploadProgressLayout)
val imageView by bind<ImageView>(R.id.messageThumbnailView)
val playContentView by bind<ImageView>(R.id.messageMediaPlayView)
val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia)
val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator)
val eventSendingIndicator by bind<ProgressBar>(R.id.eventSendingIndicator)
}
companion object {

View File

@ -42,7 +42,8 @@ data class MessageInformationData(
val readReceipts: List<ReadReceiptData> = 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)

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="M8.5714,22.5C8.5714,23.6864 9.5357,25 10.7143,25H19.2857C20.4643,25 21.4286,23.4428 21.4286,22.2564V11.4711C21.4286,10.2848 20.4643,9.3141 19.2857,9.3141H10.7143C9.5357,9.3141 8.5714,10.2848 8.5714,11.4711V22.5ZM21.4286,6.0785H18.75L17.9893,5.3128C17.7964,5.1186 17.5179,5 17.2393,5H12.7607C12.4821,5 12.2036,5.1186 12.0107,5.3128L11.25,6.0785H8.5714C7.9821,6.0785 7.5,6.5639 7.5,7.1571C7.5,7.7502 7.9821,8.2356 8.5714,8.2356H21.4286C22.0179,8.2356 22.5,7.7502 22.5,7.1571C22.5,6.5639 22.0179,6.0785 21.4286,6.0785Z"
android:fillColor="#FE2928"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:pathData="M9,16C12.866,16 16,12.866 16,9C16,5.134 12.866,2 9,2C5.134,2 2,5.134 2,9C2,12.866 5.134,16 9,16ZM9,17C13.4183,17 17,13.4183 17,9C17,4.5817 13.4183,1 9,1C4.5817,1 1,4.5817 1,9C1,13.4183 4.5817,17 9,17Z"
android:fillColor="#8D99A5"
android:fillType="evenOdd"/>
<path
android:pathData="M12.8697,5.9531C12.6784,5.7576 12.3597,5.7473 12.1578,5.9325L7.6207,10.048L5.9524,8.9163C5.7293,8.7722 5.4212,8.7722 5.2087,8.9574C4.9536,9.1632 4.9324,9.5336 5.1449,9.7805L7.0681,11.9206C7.1,11.9515 7.1319,11.9926 7.1744,12.0132C7.5356,12.3013 8.0776,12.2498 8.3751,11.9L8.4069,11.8589L12.891,6.6013C13.0397,6.4161 13.0397,6.1383 12.8697,5.9531Z"
android:fillColor="#8D99A5"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M4.0227,2.9646C5.1159,2.1 6.4987,1.5835 8,1.5835C11.3187,1.5835 14.049,4.1029 14.3825,7.3335H15.6723C15.9336,7.3335 16.0894,7.625 15.9445,7.8426L13.9388,10.8543C13.8094,11.0488 13.524,11.0488 13.3945,10.8543L11.3888,7.8426C11.2439,7.625 11.3997,7.3335 11.661,7.3335H12.8719C12.5465,4.9334 10.4893,3.0835 8,3.0835C6.8483,3.0835 5.7909,3.4786 4.9531,4.1411C4.8969,4.1856 4.8485,4.2213 4.813,4.2467C4.7951,4.2595 4.7803,4.2698 4.7692,4.2774L4.7553,4.2869L4.7505,4.2901L4.7487,4.2913L4.7479,4.2918L4.7476,4.2921L4.7474,4.2922L4.7473,4.2922L4.3334,3.6669L4.7472,4.2923C4.4018,4.5209 3.9365,4.4262 3.7079,4.0807C3.4798,3.736 3.5736,3.2719 3.9173,3.0428L3.9202,3.0408L3.9401,3.0268C3.9591,3.0132 3.988,2.992 4.0227,2.9646ZM3.1281,8.6668H4.339C4.6003,8.6668 4.7561,8.3753 4.6112,8.1577L2.6055,5.146C2.476,4.9516 2.1906,4.9516 2.0612,5.146L0.0555,8.1577C-0.0894,8.3753 0.0664,8.6668 0.3277,8.6668H1.6176C1.951,11.8974 4.6813,14.4168 8,14.4168C9.5683,14.4168 11.0069,13.8532 12.1215,12.9184C12.4388,12.6522 12.4803,12.1791 12.2141,11.8617C11.9479,11.5444 11.4749,11.5029 11.1575,11.7691C10.303,12.4859 9.2028,12.9168 8,12.9168C5.5107,12.9168 3.4535,11.0669 3.1281,8.6668Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:pathData="M9,16C12.866,16 16,12.866 16,9C16,5.134 12.866,2 9,2C5.134,2 2,5.134 2,9C2,12.866 5.134,16 9,16ZM9,17C13.4183,17 17,13.4183 17,9C17,4.5817 13.4183,1 9,1C4.5817,1 1,4.5817 1,9C1,13.4183 4.5817,17 9,17Z"
android:fillColor="#8D99A5"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,16C12.4183,16 16,12.4183 16,8C16,3.5817 12.4183,0 8,0C3.5817,0 0,3.5817 0,8C0,12.4183 3.5817,16 8,16ZM6.9806,4.5101C6.9306,3.9401 7.3506,3.4401 7.9206,3.4001C8.4806,3.3601 8.9806,3.7801 9.0406,4.3501V4.5101L8.7206,8.5101C8.6906,8.8801 8.3806,9.1601 8.0106,9.1601H7.9506C7.6006,9.1301 7.3306,8.8601 7.3006,8.5101L6.9806,4.5101ZM8.8801,11.1202C8.8801,11.6062 8.4861,12.0002 8.0001,12.0002C7.5141,12.0002 7.1201,11.6062 7.1201,11.1202C7.1201,10.6342 7.5141,10.2402 8.0001,10.2402C8.4861,10.2402 8.8801,10.6342 8.8801,11.1202Z"
android:fillColor="#FF4B55"
android:fillType="evenOdd"/>
</vector>

View File

@ -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" />
<im.vector.app.features.sync.widget.SyncStateView
android:id="@+id/syncStateView"
@ -159,6 +159,16 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<im.vector.app.core.ui.views.FailedMessagesWarningView
android:id="@+id/failedMessagesWarningView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/composerLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
<im.vector.app.features.home.room.detail.composer.TextComposerView
android:id="@+id/composerLayout"
android:layout_width="match_parent"
@ -186,7 +196,7 @@
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="top"
app:constraint_referenced_ids="composerLayout,notificationAreaView" />
app:constraint_referenced_ids="composerLayout,notificationAreaView, failedMessagesWarningView" />
<androidx.cardview.widget.CardView
android:id="@+id/activeCallPiPWrap"

View File

@ -8,8 +8,8 @@
<ImageView
android:id="@+id/bottom_sheet_message_preview_avatar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="@dimen/layout_horizontal_margin"
android:adjustViewBounds="true"
android:background="@drawable/circle"
@ -23,7 +23,7 @@
<TextView
android:id="@+id/bottom_sheet_message_preview_sender"
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginStart="8dp"
@ -31,14 +31,24 @@
android:ellipsize="end"
android:fontFamily="sans-serif-bold"
android:singleLine="true"
android:textColor="?riotx_text_primary"
android:textColor="@color/riotx_accent"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toTopOf="@id/bottom_sheet_message_preview_avatar"
tools:text="@tools:sample/full_names" />
<TextView
android:id="@+id/bottom_sheet_message_preview_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:textColor="?riotx_text_secondary"
android:textSize="12sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@id/bottom_sheet_message_preview_sender"
tools:text="Friday 8pm" />
<TextView
android:id="@+id/bottom_sheet_message_preview_body"
android:layout_width="0dp"
@ -52,22 +62,8 @@
android:textColor="?riotx_text_secondary"
android:textIsSelectable="false"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@id/bottom_sheet_message_preview_timestamp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_sender"
tools:text="Quis harum id autem cumque consequatur laboriosam aliquam sed. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. " />
<TextView
android:id="@+id/bottom_sheet_message_preview_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginBottom="8dp"
android:textColor="?riotx_text_secondary"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_body"
tools:text="Friday 8pm" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -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 @@
</FrameLayout>
<im.vector.app.core.ui.views.SendStateImageView
android:id="@+id/messageSendStateImageView"
android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size"
android:layout_alignBottom="@+id/viewStubContainer"
android:layout_alignParentEnd="true"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp"
android:contentDescription="@string/event_status_a11y_sending"
android:src="@drawable/ic_sending_message"
android:visibility="gone"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/eventSendingIndicator"
android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size"
android:layout_alignBottom="@+id/viewStubContainer"
android:indeterminateTint="?riotx_text_secondary"
android:layout_alignParentEnd="true"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp"
android:visibility="gone"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/informationBottom"
android:layout_width="match_parent"

View File

@ -55,16 +55,6 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="A filename here" />
<ProgressBar
android:id="@+id/eventSendingIndicator"
style="?android:attr/progressBarStyleSmall"
android:layout_width="16dp"
android:layout_height="16dp"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/messageFilenameView"
app:layout_constraintTop_toTopOf="@id/messageFilenameView"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/horizontalBarrier"

View File

@ -18,27 +18,6 @@
tools:layout_height="300dp"
tools:src="@tools:sample/backgrounds/scenic" />
<ImageView
android:id="@+id/messageFailToSendIndicator"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@string/a11y_error_message_not_sent"
android:src="@drawable/ic_warning_badge"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/messageThumbnailView"
app:layout_constraintTop_toTopOf="@id/messageThumbnailView"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/eventSendingIndicator"
style="?android:attr/progressBarStyleSmall"
android:layout_width="16dp"
android:layout_height="16dp"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/messageThumbnailView"
app:layout_constraintTop_toBottomOf="@id/messageFailToSendIndicator" />
<ImageView
android:id="@+id/messageMediaPlayView"
android:layout_width="40dp"

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<View
android:id="@+id/failedMessagesWarningDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:background="?attr/vctr_list_divider_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/failedMessagesWarningTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="8dp"
android:drawablePadding="8dp"
android:gravity="center_vertical"
android:text="@string/event_status_failed_messages_warning"
android:textColor="?riotx_text_primary"
android:textSize="14sp"
app:drawableStartCompat="@drawable/ic_sending_message_failed"
app:layout_constraintBottom_toBottomOf="@id/failedMessagesRetryButton"
app:layout_constraintEnd_toStartOf="@+id/failedMessagesDeleteAllButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/failedMessagesRetryButton" />
<ImageButton
android:id="@+id/failedMessagesDeleteAllButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/event_status_a11y_delete_all"
android:src="@drawable/ic_delete_unsent_messages"
app:layout_constraintBottom_toBottomOf="@id/failedMessagesRetryButton"
app:layout_constraintEnd_toStartOf="@id/failedMessagesRetryButton"
app:layout_constraintTop_toTopOf="@id/failedMessagesRetryButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/failedMessagesRetryButton"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:text="@string/global_retry"
android:textSize="14sp"
app:icon="@drawable/ic_retry_sending_messages"
app:iconTint="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/failedMessagesWarningDivider" />
</merge>

View File

@ -9,7 +9,7 @@
<TextView
android:id="@+id/receiptMore"
android:layout_width="wrap_content"
android:layout_height="18dp"
android:layout_height="@dimen/item_event_message_state_size"
android:background="@drawable/pill_receipt"
android:gravity="center"
android:importantForAccessibility="no"
@ -20,8 +20,8 @@
<ImageView
android:id="@+id/receiptAvatar5"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size"
android:layout_marginStart="2dp"
android:adjustViewBounds="true"
android:importantForAccessibility="no"
@ -30,8 +30,8 @@
<ImageView
android:id="@+id/receiptAvatar4"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size"
android:layout_marginStart="2dp"
android:adjustViewBounds="true"
android:importantForAccessibility="no"
@ -40,8 +40,8 @@
<ImageView
android:id="@+id/receiptAvatar3"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size"
android:layout_marginStart="2dp"
android:adjustViewBounds="true"
android:importantForAccessibility="no"
@ -50,8 +50,8 @@
<ImageView
android:id="@+id/receiptAvatar2"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size"
android:layout_marginStart="2dp"
android:adjustViewBounds="true"
android:importantForAccessibility="no"
@ -60,8 +60,8 @@
<ImageView
android:id="@+id/receiptAvatar1"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size"
android:layout_marginStart="2dp"
android:adjustViewBounds="true"
android:importantForAccessibility="no"

View File

@ -52,22 +52,6 @@
app:actionLayout="@layout/custom_action_item_layout_badge"
app:showAsAction="ifRoom" />
<item
android:id="@+id/resend_all"
android:icon="@drawable/ic_refresh_cw"
android:title="@string/room_prompt_resend"
android:visible="false"
app:showAsAction="never"
tools:visible="true" />
<item
android:id="@+id/clear_all"
android:icon="@drawable/ic_trash"
android:title="@string/room_prompt_cancel"
android:visible="false"
app:showAsAction="never"
tools:visible="true" />
<item
android:id="@+id/dev_tools"
android:icon="@drawable/ic_settings_root_general"

View File

@ -13,6 +13,7 @@
<dimen name="navigation_view_height">196dp</dimen>
<dimen name="navigation_avatar_top_margin">44dp</dimen>
<dimen name="item_decoration_left_margin">72dp</dimen>
<dimen name="item_event_message_state_size">16dp</dimen>
<dimen name="chat_avatar_size">40dp</dimen>
<dimen name="member_list_avatar_size">60dp</dimen>

View File

@ -2051,7 +2051,7 @@
<string name="edit">Edit</string>
<string name="reply">Reply</string>
<string name="global_retry">"Retry"</string>
<string name="global_retry">Retry</string>
<string name="room_list_empty">"Join a room to start using the app."</string>
<string name="send_you_invite">"Sent you an invitation"</string>
<string name="invited_by">Invited by %s</string>
@ -3239,4 +3239,13 @@
<string name="dev_tools_success_event">Event sent!</string>
<string name="dev_tools_success_state_event">State event sent!</string>
<string name="dev_tools_event_content_hint">Event content</string>
<string name="event_status_a11y_sending">Sending</string>
<string name="event_status_a11y_sent">Sent</string>
<string name="event_status_a11y_failed">Failed</string>
<string name="event_status_a11y_delete_all">Delete all failed messages</string>
<string name="event_status_cancel_sending_dialog_message">Do you want to cancel sending message?</string>
<string name="event_status_failed_messages_warning">Messages failed to send</string>
<string name="event_status_delete_all_failed_dialog_title">Delete unsent messages</string>
<string name="event_status_delete_all_failed_dialog_message">Are you sure you want to delete all unsent messages in this room?</string>
</resources>