Save draft of a message when exiting a room with non empty composer (#329)

This commit is contained in:
Benoit Marty 2019-09-12 15:14:17 +02:00 committed by Benoit Marty
parent c728834273
commit 36866dd24e
25 changed files with 667 additions and 140 deletions

View file

@ -2,7 +2,7 @@ Changes in RiotX 0.6.0 (2019-XX-XX)
===================================================
Features:
-
- Save draft of a message when exiting a room with non empty composer (#329)
Improvements:
- Add unread indent on room list (#485)

View file

@ -20,6 +20,7 @@ import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.send.UserDraft
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import io.reactivex.Observable
import io.reactivex.Single
@ -54,6 +55,10 @@ class RxRoom(private val room: Room) {
return room.getEventReadReceiptsLive(eventId).asObservable()
}
fun liveDrafts(): Observable<List<UserDraft>> {
return room.getDraftsLive().asObservable()
}
}
fun Room.rx(): RxRoom {

View file

@ -22,6 +22,7 @@ import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.relation.RelationService
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.DraftService
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService
import im.vector.matrix.android.api.session.room.timeline.TimelineService
@ -32,6 +33,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineService
interface Room :
TimelineService,
SendService,
DraftService,
ReadService,
MembershipService,
StateService,

View file

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.room.model
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
import im.vector.matrix.android.api.session.room.send.UserDraft
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
/**
@ -36,7 +37,8 @@ data class RoomSummary(
val hasUnreadMessages: Boolean = false,
val tags: List<RoomTag> = emptyList(),
val membership: Membership = Membership.NONE,
val versioningState: VersioningState = VersioningState.NONE
val versioningState: VersioningState = VersioningState.NONE,
val userDrafts: List<UserDraft> = emptyList()
) {
val isVersioned: Boolean

View file

@ -0,0 +1,39 @@
/*
* Copyright 2019 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.matrix.android.api.session.room.send
import androidx.lifecycle.LiveData
interface DraftService {
/**
* Save or update a draft to the room
*/
fun saveDraft(draft: UserDraft)
/**
* Delete the last draft, basically just after sending the message
*/
fun deleteDraft()
/**
* Return the current drafts if any, as a live data
* The draft list can contain one draft for {regular, reply, quote} and an arbitrary number of {edit} drafts
*/
fun getDraftsLive(): LiveData<List<UserDraft>>
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2019 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.matrix.android.api.session.room.send
/**
* Describes a user draft:
* REGULAR: draft of a classical message
* QUOTE: draft of a message which quotes another message
* EDIT: draft of an edition of a message
* REPLY: draft of a reply of another message
*/
sealed class UserDraft(open val text: String) {
data class REGULAR(override val text: String) : UserDraft(text)
data class QUOTE(val linkedEventId: String, override val text: String) : UserDraft(text)
data class EDIT(val linkedEventId: String, override val text: String) : UserDraft(text)
data class REPLY(val linkedEventId: String, override val text: String) : UserDraft(text)
fun isValid(): Boolean {
return when (this) {
is REGULAR -> text.isNotBlank()
else -> true
}
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2019 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.matrix.android.internal.database.mapper
import im.vector.matrix.android.api.session.room.send.UserDraft
import im.vector.matrix.android.internal.database.model.DraftEntity
/**
* DraftEntity <-> UserDraft
*/
internal object DraftMapper {
fun map(entity: DraftEntity): UserDraft {
return when (entity.draftMode) {
DraftEntity.MODE_REGULAR -> UserDraft.REGULAR(entity.content)
DraftEntity.MODE_EDIT -> UserDraft.EDIT(entity.linkedEventId, entity.content)
DraftEntity.MODE_QUOTE -> UserDraft.QUOTE(entity.linkedEventId, entity.content)
DraftEntity.MODE_REPLY -> UserDraft.REPLY(entity.linkedEventId, entity.content)
else -> null
} ?: UserDraft.REGULAR("")
}
fun map(domain: UserDraft): DraftEntity {
return when (domain) {
is UserDraft.REGULAR -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_REGULAR, linkedEventId = "")
is UserDraft.EDIT -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_EDIT, linkedEventId = domain.linkedEventId)
is UserDraft.QUOTE -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_QUOTE, linkedEventId = domain.linkedEventId)
is UserDraft.REPLY -> DraftEntity(content = domain.text, draftMode = DraftEntity.MODE_REPLY, linkedEventId = domain.linkedEventId)
}
}
}

View file

@ -22,7 +22,7 @@ import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import java.util.UUID
import java.util.*
import javax.inject.Inject
internal class RoomSummaryMapper @Inject constructor(
@ -67,7 +67,8 @@ internal class RoomSummaryMapper @Inject constructor(
hasUnreadMessages = roomSummaryEntity.hasUnreadMessages,
tags = tags,
membership = roomSummaryEntity.membership,
versioningState = roomSummaryEntity.versioningState
versioningState = roomSummaryEntity.versioningState,
userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList()
)
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2019 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.matrix.android.internal.database.model
import io.realm.RealmObject
internal open class DraftEntity(var content: String = "",
var draftMode: String = MODE_REGULAR,
var linkedEventId: String = ""
) : RealmObject() {
companion object {
const val MODE_REGULAR = "REGULAR"
const val MODE_EDIT = "EDIT"
const val MODE_REPLY = "REPLY"
const val MODE_QUOTE = "QUOTE"
}
}

View file

@ -36,7 +36,8 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "",
var notificationCount: Int = 0,
var highlightCount: Int = 0,
var hasUnreadMessages: Boolean = false,
var tags: RealmList<RoomTagEntity> = RealmList()
var tags: RealmList<RoomTagEntity> = RealmList(),
var userDrafts: UserDraftsEntity? = null
) : RealmObject() {
private var membershipStr: String = Membership.NONE.name

View file

@ -43,6 +43,8 @@ import io.realm.annotations.RealmModule
PushConditionEntity::class,
PusherEntity::class,
PusherDataEntity::class,
ReadReceiptsSummaryEntity::class
ReadReceiptsSummaryEntity::class,
UserDraftsEntity::class,
DraftEntity::class
])
internal class SessionRealmModule

View file

@ -0,0 +1,36 @@
/*
* Copyright 2019 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.matrix.android.internal.database.model
import io.realm.RealmList
import io.realm.RealmObject
import io.realm.RealmResults
import io.realm.annotations.LinkingObjects
/**
* Create a specific table to be able to do direct query on it and keep the draft ordered
*/
internal open class UserDraftsEntity(var userDrafts: RealmList<DraftEntity> = RealmList()
) : RealmObject() {
// Link to RoomSummaryEntity
@LinkingObjects("userDrafts")
val roomSummaryEntity: RealmResults<RoomSummaryEntity>? = null
companion object
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2019 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.matrix.android.internal.database.query
import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields
import im.vector.matrix.android.internal.database.model.UserDraftsEntity
import im.vector.matrix.android.internal.database.model.UserDraftsEntityFields
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.where
internal fun UserDraftsEntity.Companion.where(realm: Realm, roomId: String? = null): RealmQuery<UserDraftsEntity> {
val query = realm.where<UserDraftsEntity>()
if (roomId != null) {
query.equalTo(UserDraftsEntityFields.ROOM_SUMMARY_ENTITY + "." + RoomSummaryEntityFields.ROOM_ID, roomId)
}
return query
}

View file

@ -25,6 +25,7 @@ import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.relation.RelationService
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.DraftService
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService
import im.vector.matrix.android.api.session.room.timeline.TimelineService
@ -40,6 +41,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
private val roomSummaryMapper: RoomSummaryMapper,
private val timelineService: TimelineService,
private val sendService: SendService,
private val draftService: DraftService,
private val stateService: StateService,
private val readService: ReadService,
private val cryptoService: CryptoService,
@ -48,6 +50,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
) : Room,
TimelineService by timelineService,
SendService by sendService,
DraftService by draftService,
StateService by stateService,
ReadService by readService,
RelationService by relationService,

View file

@ -20,6 +20,7 @@ import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper
import im.vector.matrix.android.internal.session.room.draft.DefaultDraftService
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
import im.vector.matrix.android.internal.session.room.read.DefaultReadService
import im.vector.matrix.android.internal.session.room.relation.DefaultRelationService
@ -38,6 +39,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
private val cryptoService: CryptoService,
private val timelineServiceFactory: DefaultTimelineService.Factory,
private val sendServiceFactory: DefaultSendService.Factory,
private val draftServiceFactory: DefaultDraftService.Factory,
private val stateServiceFactory: DefaultStateService.Factory,
private val readServiceFactory: DefaultReadService.Factory,
private val relationServiceFactory: DefaultRelationService.Factory,
@ -51,6 +53,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
roomSummaryMapper,
timelineServiceFactory.create(roomId),
sendServiceFactory.create(roomId),
draftServiceFactory.create(roomId),
stateServiceFactory.create(roomId),
readServiceFactory.create(roomId),
cryptoService,

View file

@ -0,0 +1,166 @@
/*
* Copyright 2019 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.matrix.android.internal.session.room.draft
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.BuildConfig
import im.vector.matrix.android.api.session.room.send.DraftService
import im.vector.matrix.android.api.session.room.send.UserDraft
import im.vector.matrix.android.internal.database.RealmLiveData
import im.vector.matrix.android.internal.database.mapper.DraftMapper
import im.vector.matrix.android.internal.database.model.DraftEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.UserDraftsEntity
import im.vector.matrix.android.internal.database.query.where
import io.realm.kotlin.createObject
import timber.log.Timber
internal class DefaultDraftService @AssistedInject constructor(@Assisted private val roomId: String,
private val monarchy: Monarchy
) : DraftService {
@AssistedInject.Factory
interface Factory {
fun create(roomId: String): DraftService
}
/**
* The draft stack can contain several drafts. Depending of the draft to save, it will update the top draft, or create a new draft,
* or even move an existing draft to the top of the list
*/
override fun saveDraft(draft: UserDraft) {
Timber.d("Draft: saveDraft ${privacySafe(draft)}")
monarchy.writeAsync { realm ->
val roomSummaryEntity = RoomSummaryEntity.where(realm, roomId).findFirst() ?: realm.createObject(roomId)
val userDraftsEntity = roomSummaryEntity.userDrafts
?: realm.createObject<UserDraftsEntity>().also {
roomSummaryEntity.userDrafts = it
}
userDraftsEntity.let { userDraftEntity ->
// Save only valid draft
if (draft.isValid()) {
// Add a new draft or update the current one?
val newDraft = DraftMapper.map(draft)
// Is it an update of the top draft?
val topDraft = userDraftEntity.userDrafts.lastOrNull()
if (topDraft == null) {
Timber.d("Draft: create a new draft ${privacySafe(draft)}")
userDraftEntity.userDrafts.add(newDraft)
} else if (topDraft.draftMode == DraftEntity.MODE_EDIT) {
// top draft is an edit
if (newDraft.draftMode == DraftEntity.MODE_EDIT) {
if (topDraft.linkedEventId == newDraft.linkedEventId) {
// Update the top draft
Timber.d("Draft: update the top edit draft ${privacySafe(draft)}")
topDraft.content = newDraft.content
} else {
// Check a previously EDIT draft with the same id
val existingEditDraftOfSameEvent = userDraftEntity.userDrafts.find {
it.draftMode == DraftEntity.MODE_EDIT && it.linkedEventId == newDraft.linkedEventId
}
if (existingEditDraftOfSameEvent != null) {
// Ignore the new text, restore what was typed before, by putting the draft to the top
Timber.d("Draft: restore a previously edit draft ${privacySafe(draft)}")
userDraftEntity.userDrafts.remove(existingEditDraftOfSameEvent)
userDraftEntity.userDrafts.add(existingEditDraftOfSameEvent)
} else {
Timber.d("Draft: add a new edit draft ${privacySafe(draft)}")
userDraftEntity.userDrafts.add(newDraft)
}
}
} else {
// Add a new regular draft to the top
Timber.d("Draft: add a new draft ${privacySafe(draft)}")
userDraftEntity.userDrafts.add(newDraft)
}
} else {
// Top draft is not an edit
if (newDraft.draftMode == DraftEntity.MODE_EDIT) {
Timber.d("Draft: create a new edit draft ${privacySafe(draft)}")
userDraftEntity.userDrafts.add(newDraft)
} else {
// Update the top draft
Timber.d("Draft: update the top draft ${privacySafe(draft)}")
topDraft.draftMode = newDraft.draftMode
topDraft.content = newDraft.content
topDraft.linkedEventId = newDraft.linkedEventId
}
}
} else {
// There is no draft to save, so the composer was clear
Timber.d("Draft: delete a draft")
val topDraft = userDraftEntity.userDrafts.lastOrNull()
if (topDraft == null) {
Timber.d("Draft: nothing to do")
} else {
// Remove the top draft
Timber.d("Draft: remove the top draft")
userDraftEntity.userDrafts.remove(topDraft)
}
}
}
}
}
private fun privacySafe(o: Any): Any {
if (BuildConfig.LOG_PRIVATE_DATA) {
return o
}
return ""
}
override fun deleteDraft() {
Timber.d("Draft: deleteDraft()")
monarchy.writeAsync { realm ->
UserDraftsEntity.where(realm, roomId).findFirst()?.let { userDraftsEntity ->
if (userDraftsEntity.userDrafts.isNotEmpty()) {
userDraftsEntity.userDrafts.removeAt(userDraftsEntity.userDrafts.size - 1)
}
}
}
}
override fun getDraftsLive(): LiveData<List<UserDraft>> {
val liveData = RealmLiveData(monarchy.realmConfiguration) {
UserDraftsEntity.where(it, roomId)
}
return Transformations.map(liveData) { userDraftsEntities ->
userDraftsEntities.firstOrNull()?.let { userDraftEntity ->
userDraftEntity.userDrafts.map { draftEntity ->
DraftMapper.map(draftEntity)
}
} ?: emptyList()
}
}
}

View file

@ -17,33 +17,29 @@
package im.vector.matrix.android.internal.session.room.send
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.Operation
import androidx.work.WorkManager
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.work.*
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.BuildConfig
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.isImageMessage
import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.events.model.*
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.send.UserDraft
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.internal.database.RealmLiveData
import im.vector.matrix.android.internal.database.mapper.DraftMapper
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.model.*
import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.content.UploadContentWorker
@ -75,6 +71,7 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private
}
private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor()
override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable {
val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also {
saveLocalEcho(it)
@ -165,12 +162,10 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private
override fun deleteFailedEcho(localEcho: TimelineEvent) {
monarchy.writeAsync { realm ->
TimelineEventEntity.where(realm, eventId = localEcho.root.eventId
?: "").findFirst()?.let {
TimelineEventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.let {
it.deleteFromRealm()
}
EventEntity.where(realm, eventId = localEcho.root.eventId
?: "").findFirst()?.let {
EventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.let {
it.deleteFromRealm()
}
}

View file

@ -252,8 +252,9 @@ dependencies {
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation 'com.jakewharton.rxrelay2:rxrelay:2.1.0'
// RXBinding
implementation 'com.jakewharton.rxbinding3:rxbinding:3.0.0-alpha2'
implementation 'com.jakewharton.rxbinding3:rxbinding-appcompat:3.0.0-alpha2'
implementation 'com.jakewharton.rxbinding3:rxbinding:3.0.0'
implementation 'com.jakewharton.rxbinding3:rxbinding-appcompat:3.0.0'
implementation 'com.jakewharton.rxbinding3:rxbinding-material:3.0.0'
implementation("com.airbnb.android:epoxy:$epoxy_version")
kapt "com.airbnb.android:epoxy-processor:$epoxy_version"

View file

@ -18,13 +18,13 @@ package im.vector.riotx.features.home.room.detail
import com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
sealed class RoomDetailActions {
data class SaveDraft(val draft: String) : RoomDetailActions()
data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
@ -35,13 +35,15 @@ sealed class RoomDetailActions {
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailActions()
data class NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions()
data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions()
data class HandleTombstoneEvent(val event: Event): RoomDetailActions()
data class HandleTombstoneEvent(val event: Event) : RoomDetailActions()
object AcceptInvite : RoomDetailActions()
object RejectInvite : RoomDetailActions()
data class EnterEditMode(val eventId: String) : RoomDetailActions()
data class EnterQuoteMode(val eventId: String) : RoomDetailActions()
data class EnterReplyMode(val eventId: String) : RoomDetailActions()
data class EnterQuoteMode(val eventId: String, val draft: String) : RoomDetailActions()
data class EnterReplyMode(val eventId: String, val draft: String) : RoomDetailActions()
data class ExitSpecialMode(val draft: String) : RoomDetailActions()
data class ResendMessage(val eventId: String) : RoomDetailActions()
data class RemoveFailedEcho(val eventId: String) : RoomDetailActions()
object ClearSendQueue : RoomDetailActions()

View file

@ -53,6 +53,7 @@ import com.google.android.material.snackbar.Snackbar
import com.jaiselrahman.filepicker.activity.FilePickerActivity
import com.jaiselrahman.filepicker.config.Configurations
import com.jaiselrahman.filepicker.model.MediaFile
import com.jakewharton.rxbinding3.widget.afterTextChangeEvents
import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
@ -64,7 +65,6 @@ import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
@ -107,6 +107,7 @@ import im.vector.riotx.features.notifications.NotificationDrawerManager
import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
import im.vector.riotx.features.settings.VectorPreferences
import im.vector.riotx.features.themes.ThemeUtils
import io.reactivex.rxkotlin.subscribeBy
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.*
import kotlinx.android.synthetic.main.merge_composer_layout.view.*
@ -114,6 +115,7 @@ import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
import org.commonmark.parser.Parser
import timber.log.Timber
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -242,10 +244,10 @@ class RoomDetailFragment :
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode) { mode ->
when (mode) {
SendMode.REGULAR -> exitSpecialMode()
is SendMode.EDIT -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_edit, true)
is SendMode.QUOTE -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_quote, false)
is SendMode.REPLY -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_reply, false)
is SendMode.REGULAR -> renderRegularMode(mode.text)
is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, mode.text)
is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, mode.text)
is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, mode.text)
}
}
@ -300,14 +302,16 @@ class RoomDetailFragment :
return super.onOptionsItemSelected(item)
}
private fun exitSpecialMode() {
private fun renderRegularMode(text: String) {
commandAutocompletePolicy.enabled = true
composerLayout.collapse()
updateComposerText(text)
}
private fun enterSpecialMode(event: TimelineEvent,
@DrawableRes iconRes: Int,
useText: Boolean) {
private fun renderSpecialMode(event: TimelineEvent,
@DrawableRes iconRes: Int,
defaultContent: String) {
commandAutocompletePolicy.enabled = false
//switch to expanded bar
composerLayout.composerRelatedMessageTitle.apply {
@ -321,19 +325,20 @@ class RoomDetailFragment :
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody
?: messageContent.body)
?: messageContent.body)
formattedBody = eventHtmlRenderer.render(document)
}
composerLayout.composerRelatedMessageContent.text = formattedBody
?: nonFormattedBody
composerLayout.composerRelatedMessageContent.text = formattedBody ?: nonFormattedBody
updateComposerText(defaultContent)
composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
avatarRenderer.render(event.senderAvatar, event.root.senderId
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)
avatarRenderer.render(event.senderAvatar,
event.root.senderId ?: "",
event.senderName,
composerLayout.composerRelatedMessageAvatar)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
composerLayout.expand {
//need to do it here also when not using quick reply
focusComposerAndShowKeyboard()
@ -341,6 +346,16 @@ class RoomDetailFragment :
focusComposerAndShowKeyboard()
}
private fun updateComposerText(text: String) {
// Do not update if this is the same text to avoid the cursor to move
if (text != composerLayout.composerEditText.text.toString()) {
// Ignore update to avoid saving a draft
filterComposerTextChange = true
composerLayout.composerEditText.setText(text)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
}
}
override fun onResume() {
super.onResume()
@ -360,9 +375,9 @@ class RoomDetailFragment :
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
REACTION_SELECT_REQUEST_CODE -> {
val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID)
?: return
?: return
val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT)
?: return
?: return
//TODO check if already reacted with that?
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId))
}
@ -397,32 +412,46 @@ class RoomDetailFragment :
if (vectorPreferences.swipeToReplyIsEnabled()) {
val swipeCallback = RoomMessageTouchHelperCallback(requireContext(),
R.drawable.ic_reply,
object : RoomMessageTouchHelperCallback.QuickReplayHandler {
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
(model as? AbsMessageItem)?.informationData?.let {
val eventId = it.eventId
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
}
}
R.drawable.ic_reply,
object : RoomMessageTouchHelperCallback.QuickReplayHandler {
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
(model as? AbsMessageItem)?.informationData?.let {
val eventId = it.eventId
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString()))
}
}
override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
return when (model) {
is MessageFileItem,
is MessageImageVideoItem,
is MessageTextItem -> {
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
}
else -> false
}
}
})
override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
return when (model) {
is MessageFileItem,
is MessageImageVideoItem,
is MessageTextItem -> {
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
}
else -> false
}
}
})
val touchHelper = ItemTouchHelper(swipeCallback)
touchHelper.attachToRecyclerView(recyclerView)
}
}
private var filterComposerTextChange = true
private fun setupComposer() {
composerLayout.composerEditText.afterTextChangeEvents()
.debounce(100, TimeUnit.MILLISECONDS)
.subscribeBy {
if (filterComposerTextChange) {
Timber.d("Draft: ignore text update")
filterComposerTextChange = false
return@subscribeBy
}
roomDetailViewModel.process(RoomDetailActions.SaveDraft(it.editable.toString()))
}
.disposeOnDestroy()
val elevation = 6f
val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(requireContext(), R.attr.riotx_background))
Autocomplete.on<Command>(composerLayout.composerEditText)
@ -492,8 +521,7 @@ class RoomDetailFragment :
}
}
composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
composerLayout.composerEditText.setText("")
roomDetailViewModel.resetSendMode()
roomDetailViewModel.process(RoomDetailActions.ExitSpecialMode(composerLayout.composerEditText.text.toString()))
}
}
@ -645,13 +673,11 @@ class RoomDetailFragment :
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) {
is SendMessageResult.MessageSent -> {
// Clear composer
composerLayout.composerEditText.text = null
// Nothing to do, the composer will be cleared with the draft update
}
is SendMessageResult.SlashCommandHandled -> {
sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) }
// Clear composer
composerLayout.composerEditText.text = null
// The composer will be cleared with the draft update
}
is SendMessageResult.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
@ -916,10 +942,10 @@ class RoomDetailFragment :
roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId))
}
is SimpleAction.Quote -> {
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId))
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId, composerLayout.composerEditText.text.toString()))
}
is SimpleAction.Reply -> {
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId))
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId, composerLayout.composerEditText.text.toString()))
}
is SimpleAction.CopyPermalink -> {
val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId)

View file

@ -42,8 +42,9 @@ import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.send.UserDraft
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.matrix.rx.rx
@ -84,6 +85,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private var timeline = room.createTimeline(eventId, timelineSettings)
// Filter to avoid infinite loop when user enter text in the composer and call SaveDraft
private var filterDraftUpdate = false
// Slot to keep a pending action during permission request
var pendingAction: RoomDetailActions? = null
@ -109,6 +113,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
observeRoomSummary()
observeEventDisplayedActions()
observeSummaryState()
observeDrafts()
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
@ -116,6 +121,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
fun process(action: RoomDetailActions) {
when (action) {
is RoomDetailActions.SaveDraft -> handleSaveDraft(action)
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
@ -129,6 +135,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.EnterEditMode -> handleEditAction(action)
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
is RoomDetailActions.ExitSpecialMode -> handleExitSpecialMode(action)
is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action)
@ -140,9 +147,64 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}
/**
* Convert a send mode to a draft and save the draft
*/
private fun handleSaveDraft(action: RoomDetailActions.SaveDraft) {
// The text is changed, ignore the next update from DB
filterDraftUpdate = true
withState {
when (it.sendMode) {
is SendMode.REGULAR -> room.saveDraft(UserDraft.REGULAR(action.draft))
is SendMode.REPLY -> room.saveDraft(UserDraft.REPLY(it.sendMode.timelineEvent.root.eventId!!, action.draft))
is SendMode.QUOTE -> room.saveDraft(UserDraft.QUOTE(it.sendMode.timelineEvent.root.eventId!!, action.draft))
is SendMode.EDIT -> room.saveDraft(UserDraft.EDIT(it.sendMode.timelineEvent.root.eventId!!, action.draft))
}
}
}
private fun observeDrafts() {
room.rx().liveDrafts()
.subscribe {
Timber.d("Draft update!")
if (filterDraftUpdate) {
Timber.d(" --> Ignore")
return@subscribe
}
Timber.d(" --> SetState")
setState {
val draft = it.lastOrNull() ?: UserDraft.REGULAR("")
copy(
// Create a sendMode from a draft and retrieve the TimelineEvent
sendMode = when (draft) {
is UserDraft.REGULAR -> SendMode.REGULAR(draft.text)
is UserDraft.QUOTE -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.QUOTE(timelineEvent, draft.text)
}
}
is UserDraft.REPLY -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.REPLY(timelineEvent, draft.text)
}
}
is UserDraft.EDIT -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.EDIT(timelineEvent, draft.text)
}
}
} ?: SendMode.REGULAR("")
)
}
}
.disposeOnClear()
}
private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) {
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>()
?: return
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() ?: return
val roomId = tombstoneContent.replacementRoom ?: ""
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
@ -166,22 +228,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
private fun enterEditMode(event: TimelineEvent) {
setState {
copy(
sendMode = SendMode.EDIT(event)
)
}
}
fun resetSendMode() {
setState {
copy(
sendMode = SendMode.REGULAR
)
}
}
private val _nonBlockingPopAlert = MutableLiveData<LiveEvent<Pair<Int, List<Any>>>>()
val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>>
get() = _nonBlockingPopAlert
@ -218,7 +264,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
withState { state ->
when (state.sendMode) {
SendMode.REGULAR -> {
is SendMode.REGULAR -> {
val slashCommandResult = CommandParser.parseSplashCommand(action.text)
when (slashCommandResult) {
@ -226,6 +272,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
// Send the text message to the room
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
}
is ParsedCommand.ErrorSyntax -> {
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command))
@ -238,6 +285,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
is ParsedCommand.Invite -> {
handleInviteSlashCommand(slashCommandResult)
popDraft()
}
is ParsedCommand.SetUserPowerLevel -> {
// TODO
@ -251,6 +299,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
vectorPreferences.setMarkdownEnabled(slashCommandResult.enable)
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled(
if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled))
popDraft()
}
is ParsedCommand.UnbanUser -> {
// TODO
@ -275,9 +324,11 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled())
popDraft()
}
is ParsedCommand.ChangeTopic -> {
handleChangeTopicSlashCommand(slashCommandResult)
popDraft()
}
is ParsedCommand.ChangeDisplayName -> {
// TODO
@ -285,11 +336,11 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}
}
is SendMode.EDIT -> {
is SendMode.EDIT -> {
//is original event a reply?
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
if (inReplyTo != null) {
//TODO check if same content?
room.getTimeLineEvent(inReplyTo)?.let {
@ -298,27 +349,24 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} else {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val existingBody = messageContent?.body ?: ""
if (existingBody != action.text) {
room.editTextMessage(state.sendMode.timelineEvent.root.eventId
?: "", messageContent?.type
?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "",
messageContent?.type ?: MessageType.MSGTYPE_TEXT,
action.text,
action.autoMarkdown)
} else {
Timber.w("Same message content, do not send edition")
}
}
setState {
copy(
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
}
is SendMode.QUOTE -> {
is SendMode.QUOTE -> {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val textMsg = messageContent?.body
val finalText = legacyRiotQuoteText(textMsg, action.text)
@ -333,29 +381,25 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} else {
room.sendFormattedTextMessage(finalText, htmlText)
}
setState {
copy(
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
}
is SendMode.REPLY -> {
is SendMode.REPLY -> {
state.sendMode.timelineEvent.let {
room.replyToMessage(it, action.text, action.autoMarkdown)
setState {
copy(
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
}
}
}
}
}
private fun popDraft() {
filterDraftUpdate = false
room.deleteDraft()
}
private fun legacyRiotQuoteText(quotedText: String?, myText: String): String {
val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray()
var quotedTextMsg = StringBuilder()
@ -469,27 +513,56 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
private fun handleEditAction(action: RoomDetailActions.EnterEditMode) {
room.getTimeLineEvent(action.eventId)?.let {
enterEditMode(it)
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
timelineEvent.root.eventId?.let {
filterDraftUpdate = false
room.saveDraft(UserDraft.EDIT(it, timelineEvent.getTextEditableContent() ?: ""))
}
}
}
private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) {
room.getTimeLineEvent(action.eventId)?.let {
setState {
copy(
sendMode = SendMode.QUOTE(it)
)
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
withState { state ->
// Save a new draft and keep the previously entered text, if it was not an edit
timelineEvent.root.eventId?.let {
filterDraftUpdate = false
if (state.sendMode is SendMode.EDIT) {
room.saveDraft(UserDraft.QUOTE(it, ""))
} else {
room.saveDraft(UserDraft.QUOTE(it, action.draft))
}
}
}
}
}
private fun handleReplyAction(action: RoomDetailActions.EnterReplyMode) {
room.getTimeLineEvent(action.eventId)?.let {
setState {
copy(
sendMode = SendMode.REPLY(it)
)
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
withState { state ->
// Save a new draft and keep the previously entered text, if it was not an edit
timelineEvent.root.eventId?.let {
filterDraftUpdate = false
if (state.sendMode is SendMode.EDIT) {
room.saveDraft(UserDraft.REPLY(it, ""))
} else {
room.saveDraft(UserDraft.REPLY(it, action.draft))
}
}
}
}
}
private fun handleExitSpecialMode(action: RoomDetailActions.ExitSpecialMode) {
withState { state ->
// For edit, just delete the current draft
filterDraftUpdate = false
if (state.sendMode is SendMode.EDIT) {
room.deleteDraft()
} else {
// Save a new draft and keep the previously entered text
room.saveDraft(UserDraft.REGULAR(action.draft))
}
}
}

View file

@ -34,11 +34,11 @@ import im.vector.matrix.android.api.session.user.model.User
*
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
*/
sealed class SendMode {
object REGULAR : SendMode()
data class QUOTE(val timelineEvent: TimelineEvent) : SendMode()
data class EDIT(val timelineEvent: TimelineEvent) : SendMode()
data class REPLY(val timelineEvent: TimelineEvent) : SendMode()
sealed class SendMode(open val text: String) {
data class REGULAR(override val text: String) : SendMode(text)
data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
}
data class RoomDetailViewState(
@ -47,7 +47,7 @@ data class RoomDetailViewState(
val timeline: Timeline? = null,
val asyncInviter: Async<User> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val sendMode: SendMode = SendMode.REGULAR,
val sendMode: SendMode = SendMode.REGULAR(""),
val isEncrypted: Boolean = false,
val tombstoneEvent: Event? = null,
val tombstoneEventHandling: Async<String> = Uninitialized,

View file

@ -40,6 +40,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
@EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var unreadNotificationCount: Int = 0
@EpoxyAttribute var hasUnreadMessage: Boolean = false
@EpoxyAttribute var hasDraft: Boolean = false
@EpoxyAttribute var showHighlighted: Boolean = false
@EpoxyAttribute var listener: (() -> Unit)? = null
@ -52,6 +53,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
holder.lastEventView.text = lastFormattedEvent
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
holder.unreadIndentIndicator.isVisible = hasUnreadMessage
holder.draftView.isVisible = hasDraft
avatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
}
@ -60,6 +62,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomUnreadCounterBadgeView)
val unreadIndentIndicator by bind<View>(R.id.roomUnreadIndicator)
val lastEventView by bind<TextView>(R.id.roomLastEventView)
val draftView by bind<ImageView>(R.id.roomDraftBadge)
val lastEventTimeView by bind<TextView>(R.id.roomLastEventTimeView)
val avatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
val rootView by bind<ViewGroup>(R.id.itemRoomLayout)

View file

@ -133,6 +133,7 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
.showHighlighted(showHighlighted)
.unreadNotificationCount(unreadCount)
.hasUnreadMessage(roomSummary.hasUnreadMessages)
.hasDraft(roomSummary.userDrafts.isNotEmpty())
.listener { listener?.onRoomSelected(roomSummary) }
}

View file

@ -56,13 +56,27 @@
android:textSize="15sp"
android:textStyle="bold"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@+id/roomUnreadCounterBadgeView"
app:layout_constraintEnd_toStartOf="@+id/roomDraftBadge"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/roomAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/matrix.json/data/displayName" />
<ImageView
android:id="@+id/roomDraftBadge"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginLeft="4dp"
android:layout_marginRight="4dp"
android:src="@drawable/ic_edit"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/roomNameView"
app:layout_constraintEnd_toStartOf="@+id/roomUnreadCounterBadgeView"
app:layout_constraintStart_toEndOf="@+id/roomNameView"
app:layout_constraintTop_toTopOf="@+id/roomNameView"
tools:visibility="visible" />
<im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
android:id="@+id/roomUnreadCounterBadgeView"
android:layout_width="wrap_content"
@ -76,12 +90,14 @@
android:paddingRight="4dp"
android:textColor="@android:color/white"
android:textSize="10sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/roomNameView"
app:layout_constraintEnd_toStartOf="@+id/roomLastEventTimeView"
app:layout_constraintStart_toEndOf="@+id/roomNameView"
app:layout_constraintStart_toEndOf="@+id/roomDraftBadge"
app:layout_constraintTop_toTopOf="@+id/roomNameView"
tools:background="@drawable/bg_unread_highlight"
tools:text="4" />
tools:text="4"
tools:visibility="visible" />
<TextView
android:id="@+id/roomLastEventTimeView"