Merge pull request #552 from vector-im/feature/draft

Save draft of a message when exiting a room with non empty composer (#329)
This commit is contained in:
Benoit Marty 2019-09-19 13:11:35 +02:00 committed by GitHub
commit 91cc78d2ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 666 additions and 146 deletions

View File

@ -2,7 +2,7 @@ Changes in RiotX 0.6.0 (2019-XX-XX)
=================================================== ===================================================
Features: Features:
- - Save draft of a message when exiting a room with non empty composer (#329)
Improvements: Improvements:
- Add unread indent on room list (#485) - 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.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.ReadReceipt 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.model.RoomSummary
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.session.room.timeline.TimelineEvent
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.Single import io.reactivex.Single
@ -54,6 +55,10 @@ class RxRoom(private val room: Room) {
return room.getEventReadReceiptsLive(eventId).asObservable() return room.getEventReadReceiptsLive(eventId).asObservable()
} }
fun liveDrafts(): Observable<List<UserDraft>> {
return room.getDraftsLive().asObservable()
}
} }
fun Room.rx(): RxRoom { 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.RoomSummary
import im.vector.matrix.android.api.session.room.model.relation.RelationService 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.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.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService import im.vector.matrix.android.api.session.room.state.StateService
import im.vector.matrix.android.api.session.room.timeline.TimelineService 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 : interface Room :
TimelineService, TimelineService,
SendService, SendService,
DraftService,
ReadService, ReadService,
MembershipService, MembershipService,
StateService, StateService,

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.room.model 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.model.tag.RoomTag
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.session.room.timeline.TimelineEvent
/** /**
@ -36,7 +37,8 @@ data class RoomSummary(
val hasUnreadMessages: Boolean = false, val hasUnreadMessages: Boolean = false,
val tags: List<RoomTag> = emptyList(), val tags: List<RoomTag> = emptyList(),
val membership: Membership = Membership.NONE, val membership: Membership = Membership.NONE,
val versioningState: VersioningState = VersioningState.NONE val versioningState: VersioningState = VersioningState.NONE,
val userDrafts: List<UserDraft> = emptyList()
) { ) {
val isVersioned: Boolean 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.api.session.room.model.tag.RoomTag
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import java.util.UUID import java.util.*
import javax.inject.Inject import javax.inject.Inject
internal class RoomSummaryMapper @Inject constructor( internal class RoomSummaryMapper @Inject constructor(
@ -67,7 +67,8 @@ internal class RoomSummaryMapper @Inject constructor(
hasUnreadMessages = roomSummaryEntity.hasUnreadMessages, hasUnreadMessages = roomSummaryEntity.hasUnreadMessages,
tags = tags, tags = tags,
membership = roomSummaryEntity.membership, 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 notificationCount: Int = 0,
var highlightCount: Int = 0, var highlightCount: Int = 0,
var hasUnreadMessages: Boolean = false, var hasUnreadMessages: Boolean = false,
var tags: RealmList<RoomTagEntity> = RealmList() var tags: RealmList<RoomTagEntity> = RealmList(),
var userDrafts: UserDraftsEntity? = null
) : RealmObject() { ) : RealmObject() {
private var membershipStr: String = Membership.NONE.name private var membershipStr: String = Membership.NONE.name

View File

@ -43,6 +43,8 @@ import io.realm.annotations.RealmModule
PushConditionEntity::class, PushConditionEntity::class,
PusherEntity::class, PusherEntity::class,
PusherDataEntity::class, PusherDataEntity::class,
ReadReceiptsSummaryEntity::class ReadReceiptsSummaryEntity::class,
UserDraftsEntity::class,
DraftEntity::class
]) ])
internal class SessionRealmModule 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.RoomSummary
import im.vector.matrix.android.api.session.room.model.relation.RelationService 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.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.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService import im.vector.matrix.android.api.session.room.state.StateService
import im.vector.matrix.android.api.session.room.timeline.TimelineService 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 roomSummaryMapper: RoomSummaryMapper,
private val timelineService: TimelineService, private val timelineService: TimelineService,
private val sendService: SendService, private val sendService: SendService,
private val draftService: DraftService,
private val stateService: StateService, private val stateService: StateService,
private val readService: ReadService, private val readService: ReadService,
private val cryptoService: CryptoService, private val cryptoService: CryptoService,
@ -48,6 +50,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
) : Room, ) : Room,
TimelineService by timelineService, TimelineService by timelineService,
SendService by sendService, SendService by sendService,
DraftService by draftService,
StateService by stateService, StateService by stateService,
ReadService by readService, ReadService by readService,
RelationService by relationService, 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.crypto.CryptoService
import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper 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.membership.DefaultMembershipService
import im.vector.matrix.android.internal.session.room.read.DefaultReadService import im.vector.matrix.android.internal.session.room.read.DefaultReadService
import im.vector.matrix.android.internal.session.room.relation.DefaultRelationService 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 cryptoService: CryptoService,
private val timelineServiceFactory: DefaultTimelineService.Factory, private val timelineServiceFactory: DefaultTimelineService.Factory,
private val sendServiceFactory: DefaultSendService.Factory, private val sendServiceFactory: DefaultSendService.Factory,
private val draftServiceFactory: DefaultDraftService.Factory,
private val stateServiceFactory: DefaultStateService.Factory, private val stateServiceFactory: DefaultStateService.Factory,
private val readServiceFactory: DefaultReadService.Factory, private val readServiceFactory: DefaultReadService.Factory,
private val relationServiceFactory: DefaultRelationService.Factory, private val relationServiceFactory: DefaultRelationService.Factory,
@ -51,6 +53,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
roomSummaryMapper, roomSummaryMapper,
timelineServiceFactory.create(roomId), timelineServiceFactory.create(roomId),
sendServiceFactory.create(roomId), sendServiceFactory.create(roomId),
draftServiceFactory.create(roomId),
stateServiceFactory.create(roomId), stateServiceFactory.create(roomId),
readServiceFactory.create(roomId), readServiceFactory.create(roomId),
cryptoService, 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 package im.vector.matrix.android.internal.session.room.send
import android.content.Context import android.content.Context
import androidx.work.BackoffPolicy import androidx.lifecycle.LiveData
import androidx.work.ExistingWorkPolicy import androidx.lifecycle.Transformations
import androidx.work.OneTimeWorkRequest import androidx.work.*
import androidx.work.Operation
import androidx.work.WorkManager
import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject import com.squareup.inject.assisted.AssistedInject
import com.zhuinden.monarchy.Monarchy 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.auth.data.Credentials
import im.vector.matrix.android.api.session.content.ContentAttachmentData 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.crypto.CryptoService
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.*
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.room.model.message.MessageContent 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.MessageType
import im.vector.matrix.android.api.session.room.send.SendService 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.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.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.api.util.CancelableBag 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.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.*
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.query.findAllInRoomWithSendStates import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.content.UploadContentWorker 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() private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor()
override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable { override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable {
val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also { val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also {
saveLocalEcho(it) saveLocalEcho(it)
@ -165,12 +162,10 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private
override fun deleteFailedEcho(localEcho: TimelineEvent) { override fun deleteFailedEcho(localEcho: TimelineEvent) {
monarchy.writeAsync { realm -> monarchy.writeAsync { realm ->
TimelineEventEntity.where(realm, eventId = localEcho.root.eventId TimelineEventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.let {
?: "").findFirst()?.let {
it.deleteFromRealm() it.deleteFromRealm()
} }
EventEntity.where(realm, eventId = localEcho.root.eventId EventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.let {
?: "").findFirst()?.let {
it.deleteFromRealm() it.deleteFromRealm()
} }
} }

View File

@ -252,8 +252,9 @@ dependencies {
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation 'com.jakewharton.rxrelay2:rxrelay:2.1.0' implementation 'com.jakewharton.rxrelay2:rxrelay:2.1.0'
// RXBinding // RXBinding
implementation 'com.jakewharton.rxbinding3:rxbinding:3.0.0-alpha2' implementation 'com.jakewharton.rxbinding3:rxbinding:3.0.0'
implementation 'com.jakewharton.rxbinding3:rxbinding-appcompat:3.0.0-alpha2' 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") implementation("com.airbnb.android:epoxy:$epoxy_version")
kapt "com.airbnb.android:epoxy-processor:$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 com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.events.model.Event 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.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
sealed class RoomDetailActions { sealed class RoomDetailActions {
data class SaveDraft(val draft: String) : RoomDetailActions()
data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions() data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions() data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions() data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
@ -39,9 +39,11 @@ sealed class RoomDetailActions {
object AcceptInvite : RoomDetailActions() object AcceptInvite : RoomDetailActions()
object RejectInvite : RoomDetailActions() object RejectInvite : RoomDetailActions()
data class EnterEditMode(val eventId: String) : RoomDetailActions() data class EnterEditMode(val eventId: String, val draft: String) : RoomDetailActions()
data class EnterQuoteMode(val eventId: String) : RoomDetailActions() data class EnterQuoteMode(val eventId: String, val draft: String) : RoomDetailActions()
data class EnterReplyMode(val eventId: 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 ResendMessage(val eventId: String) : RoomDetailActions()
data class RemoveFailedEcho(val eventId: String) : RoomDetailActions() data class RemoveFailedEcho(val eventId: String) : RoomDetailActions()
object ClearSendQueue : RoomDetailActions() object ClearSendQueue : RoomDetailActions()

View File

@ -64,7 +64,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.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent 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.getLastMessageContent
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.di.ScreenComponent
@ -187,7 +186,6 @@ class RoomDetailFragment :
@Inject lateinit var eventHtmlRenderer: EventHtmlRenderer @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer
@Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var vectorPreferences: VectorPreferences
override fun getLayoutResId() = R.layout.fragment_room_detail override fun getLayoutResId() = R.layout.fragment_room_detail
override fun getMenuRes() = R.menu.menu_timeline override fun getMenuRes() = R.menu.menu_timeline
@ -197,6 +195,8 @@ class RoomDetailFragment :
@BindView(R.id.composerLayout) @BindView(R.id.composerLayout)
lateinit var composerLayout: TextComposerView lateinit var composerLayout: TextComposerView
private var lockSendButton = false
override fun injectWith(injector: ScreenComponent) { override fun injectWith(injector: ScreenComponent) {
injector.inject(this) injector.inject(this)
} }
@ -242,10 +242,10 @@ class RoomDetailFragment :
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode) { mode -> roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode) { mode ->
when (mode) { when (mode) {
SendMode.REGULAR -> exitSpecialMode() is SendMode.REGULAR -> renderRegularMode(mode.text)
is SendMode.EDIT -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_edit, true) is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, mode.text)
is SendMode.QUOTE -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_quote, false) is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, mode.text)
is SendMode.REPLY -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_reply, false) is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, mode.text)
} }
} }
@ -300,14 +300,16 @@ class RoomDetailFragment :
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
private fun exitSpecialMode() { private fun renderRegularMode(text: String) {
commandAutocompletePolicy.enabled = true commandAutocompletePolicy.enabled = true
composerLayout.collapse() composerLayout.collapse()
updateComposerText(text)
} }
private fun enterSpecialMode(event: TimelineEvent, private fun renderSpecialMode(event: TimelineEvent,
@DrawableRes iconRes: Int, @DrawableRes iconRes: Int,
useText: Boolean) { defaultContent: String) {
commandAutocompletePolicy.enabled = false commandAutocompletePolicy.enabled = false
//switch to expanded bar //switch to expanded bar
composerLayout.composerRelatedMessageTitle.apply { composerLayout.composerRelatedMessageTitle.apply {
@ -324,16 +326,17 @@ class RoomDetailFragment :
?: messageContent.body) ?: messageContent.body)
formattedBody = eventHtmlRenderer.render(document) formattedBody = eventHtmlRenderer.render(document)
} }
composerLayout.composerRelatedMessageContent.text = formattedBody composerLayout.composerRelatedMessageContent.text = formattedBody ?: nonFormattedBody
?: nonFormattedBody
updateComposerText(defaultContent)
composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
avatarRenderer.render(event.senderAvatar, event.root.senderId avatarRenderer.render(event.senderAvatar,
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) event.root.senderId ?: "",
event.senderName,
composerLayout.composerRelatedMessageAvatar)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
composerLayout.expand { composerLayout.expand {
//need to do it here also when not using quick reply //need to do it here also when not using quick reply
focusComposerAndShowKeyboard() focusComposerAndShowKeyboard()
@ -341,6 +344,15 @@ class RoomDetailFragment :
focusComposerAndShowKeyboard() 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
composerLayout.composerEditText.setText(text)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@ -351,6 +363,8 @@ class RoomDetailFragment :
super.onPause() super.onPause()
notificationDrawerManager.setCurrentRoom(null) notificationDrawerManager.setCurrentRoom(null)
roomDetailViewModel.process(RoomDetailActions.SaveDraft(composerLayout.composerEditText.text.toString()))
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@ -402,7 +416,7 @@ class RoomDetailFragment :
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
(model as? AbsMessageItem)?.informationData?.let { (model as? AbsMessageItem)?.informationData?.let {
val eventId = it.eventId val eventId = it.eventId
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString()))
} }
} }
@ -486,14 +500,18 @@ class RoomDetailFragment :
.build() .build()
composerLayout.sendButton.setOnClickListener { composerLayout.sendButton.setOnClickListener {
if (lockSendButton) {
Timber.w("Send button is locked")
return@setOnClickListener
}
val textMessage = composerLayout.composerEditText.text.toString() val textMessage = composerLayout.composerEditText.text.toString()
if (textMessage.isNotBlank()) { if (textMessage.isNotBlank()) {
lockSendButton = true
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage, vectorPreferences.isMarkdownEnabled())) roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage, vectorPreferences.isMarkdownEnabled()))
} }
} }
composerLayout.composerRelatedMessageCloseButton.setOnClickListener { composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
composerLayout.composerEditText.setText("") roomDetailViewModel.process(RoomDetailActions.ExitSpecialMode(composerLayout.composerEditText.text.toString()))
roomDetailViewModel.resetSendMode()
} }
} }
@ -645,13 +663,11 @@ class RoomDetailFragment :
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) { private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) { when (sendMessageResult) {
is SendMessageResult.MessageSent -> { is SendMessageResult.MessageSent -> {
// Clear composer updateComposerText("")
composerLayout.composerEditText.text = null
} }
is SendMessageResult.SlashCommandHandled -> { is SendMessageResult.SlashCommandHandled -> {
sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) } sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) }
// Clear composer updateComposerText("")
composerLayout.composerEditText.text = null
} }
is SendMessageResult.SlashCommandError -> { is SendMessageResult.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
@ -660,7 +676,7 @@ class RoomDetailFragment :
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
} }
is SendMessageResult.SlashCommandResultOk -> { is SendMessageResult.SlashCommandResultOk -> {
// Ignore updateComposerText("")
} }
is SendMessageResult.SlashCommandResultError -> { is SendMessageResult.SlashCommandResultError -> {
displayCommandError(sendMessageResult.throwable.localizedMessage) displayCommandError(sendMessageResult.throwable.localizedMessage)
@ -669,6 +685,8 @@ class RoomDetailFragment :
displayCommandError(getString(R.string.not_implemented)) displayCommandError(getString(R.string.not_implemented))
} }
} }
lockSendButton = false
} }
private fun displayCommandError(message: String) { private fun displayCommandError(message: String) {
@ -913,13 +931,13 @@ class RoomDetailFragment :
roomDetailViewModel.process(RoomDetailActions.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) roomDetailViewModel.process(RoomDetailActions.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
} }
is SimpleAction.Edit -> { is SimpleAction.Edit -> {
roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId)) roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId, composerLayout.composerEditText.text.toString()))
} }
is SimpleAction.Quote -> { is SimpleAction.Quote -> {
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId)) roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId, composerLayout.composerEditText.text.toString()))
} }
is SimpleAction.Reply -> { is SimpleAction.Reply -> {
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId)) roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId, composerLayout.composerEditText.text.toString()))
} }
is SimpleAction.CopyPermalink -> { is SimpleAction.CopyPermalink -> {
val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId) 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.MessageType
import im.vector.matrix.android.api.session.room.model.message.getFileUrl 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.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.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.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
@ -109,6 +110,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
observeRoomSummary() observeRoomSummary()
observeEventDisplayedActions() observeEventDisplayedActions()
observeSummaryState() observeSummaryState()
observeDrafts()
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
timeline.start() timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) } setState { copy(timeline = this@RoomDetailViewModel.timeline) }
@ -116,6 +118,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
fun process(action: RoomDetailActions) { fun process(action: RoomDetailActions) {
when (action) { when (action) {
is RoomDetailActions.SaveDraft -> handleSaveDraft(action)
is RoomDetailActions.SendMessage -> handleSendMessage(action) is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.SendMedia -> handleSendMedia(action) is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
@ -129,6 +132,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.EnterEditMode -> handleEditAction(action) is RoomDetailActions.EnterEditMode -> handleEditAction(action)
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action) is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action) is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
is RoomDetailActions.ExitSpecialMode -> handleExitSpecialMode(action)
is RoomDetailActions.DownloadFile -> handleDownloadFile(action) is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action) is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action) is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action)
@ -140,9 +144,54 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} }
} }
/**
* Convert a send mode to a draft and save the draft
*/
private fun handleSaveDraft(action: RoomDetailActions.SaveDraft) {
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 --> 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) { private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) {
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() ?: return
?: return
val roomId = tombstoneContent.replacementRoom ?: "" val roomId = tombstoneContent.replacementRoom ?: ""
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
@ -166,22 +215,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>>>>() private val _nonBlockingPopAlert = MutableLiveData<LiveEvent<Pair<Int, List<Any>>>>()
val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>> val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>>
get() = _nonBlockingPopAlert get() = _nonBlockingPopAlert
@ -218,7 +251,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private fun handleSendMessage(action: RoomDetailActions.SendMessage) { private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
withState { state -> withState { state ->
when (state.sendMode) { when (state.sendMode) {
SendMode.REGULAR -> { is SendMode.REGULAR -> {
val slashCommandResult = CommandParser.parseSplashCommand(action.text) val slashCommandResult = CommandParser.parseSplashCommand(action.text)
when (slashCommandResult) { when (slashCommandResult) {
@ -226,6 +259,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
// Send the text message to the room // Send the text message to the room
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent) _sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
} }
is ParsedCommand.ErrorSyntax -> { is ParsedCommand.ErrorSyntax -> {
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)) _sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command))
@ -238,6 +272,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} }
is ParsedCommand.Invite -> { is ParsedCommand.Invite -> {
handleInviteSlashCommand(slashCommandResult) handleInviteSlashCommand(slashCommandResult)
popDraft()
} }
is ParsedCommand.SetUserPowerLevel -> { is ParsedCommand.SetUserPowerLevel -> {
// TODO // TODO
@ -251,6 +286,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
vectorPreferences.setMarkdownEnabled(slashCommandResult.enable) vectorPreferences.setMarkdownEnabled(slashCommandResult.enable)
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled( _sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled(
if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled))
popDraft()
} }
is ParsedCommand.UnbanUser -> { is ParsedCommand.UnbanUser -> {
// TODO // TODO
@ -275,9 +311,11 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is ParsedCommand.SendEmote -> { is ParsedCommand.SendEmote -> {
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE) room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled()) _sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled())
popDraft()
} }
is ParsedCommand.ChangeTopic -> { is ParsedCommand.ChangeTopic -> {
handleChangeTopicSlashCommand(slashCommandResult) handleChangeTopicSlashCommand(slashCommandResult)
popDraft()
} }
is ParsedCommand.ChangeDisplayName -> { is ParsedCommand.ChangeDisplayName -> {
// TODO // TODO
@ -286,7 +324,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} }
} }
is SendMode.EDIT -> { is SendMode.EDIT -> {
//is original event a reply? //is original event a reply?
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId 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
@ -301,19 +338,16 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
?: state.sendMode.timelineEvent.root.getClearContent().toModel() ?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val existingBody = messageContent?.body ?: "" val existingBody = messageContent?.body ?: ""
if (existingBody != action.text) { if (existingBody != action.text) {
room.editTextMessage(state.sendMode.timelineEvent.root.eventId room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "",
?: "", messageContent?.type messageContent?.type ?: MessageType.MSGTYPE_TEXT,
?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown) action.text,
action.autoMarkdown)
} else { } else {
Timber.w("Same message content, do not send edition") Timber.w("Same message content, do not send edition")
} }
} }
setState {
copy(
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent) _sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
} }
is SendMode.QUOTE -> { is SendMode.QUOTE -> {
val messageContent: MessageContent? = val messageContent: MessageContent? =
@ -333,27 +367,22 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} else { } else {
room.sendFormattedTextMessage(finalText, htmlText) room.sendFormattedTextMessage(finalText, htmlText)
} }
setState {
copy(
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent) _sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
} }
is SendMode.REPLY -> { is SendMode.REPLY -> {
state.sendMode.timelineEvent.let { state.sendMode.timelineEvent.let {
room.replyToMessage(it, action.text, action.autoMarkdown) room.replyToMessage(it, action.text, action.autoMarkdown)
setState {
copy(
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent) _sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
popDraft()
}
}
}
}
} }
} private fun popDraft() {
} room.deleteDraft()
}
} }
private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { private fun legacyRiotQuoteText(quotedText: String?, myText: String): String {
@ -469,27 +498,71 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} }
private fun handleEditAction(action: RoomDetailActions.EnterEditMode) { private fun handleEditAction(action: RoomDetailActions.EnterEditMode) {
room.getTimeLineEvent(action.eventId)?.let { saveCurrentDraft(action.draft)
enterEditMode(it)
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
timelineEvent.root.eventId?.let {
room.saveDraft(UserDraft.EDIT(it, timelineEvent.getTextEditableContent() ?: ""))
}
} }
} }
private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) { private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) {
room.getTimeLineEvent(action.eventId)?.let { saveCurrentDraft(action.draft)
setState {
copy( room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
sendMode = SendMode.QUOTE(it) withState { state ->
) // Save a new draft and keep the previously entered text, if it was not an edit
timelineEvent.root.eventId?.let {
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) { private fun handleReplyAction(action: RoomDetailActions.EnterReplyMode) {
room.getTimeLineEvent(action.eventId)?.let { saveCurrentDraft(action.draft)
setState {
copy( room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
sendMode = SendMode.REPLY(it) withState { state ->
) // Save a new draft and keep the previously entered text, if it was not an edit
timelineEvent.root.eventId?.let {
if (state.sendMode is SendMode.EDIT) {
room.saveDraft(UserDraft.REPLY(it, ""))
} else {
room.saveDraft(UserDraft.REPLY(it, action.draft))
}
}
}
}
}
private fun saveCurrentDraft(draft: String) {
// Save the draft with the current text if any
withState {
if (draft.isNotBlank()) {
when (it.sendMode) {
is SendMode.REGULAR -> room.saveDraft(UserDraft.REGULAR(draft))
is SendMode.REPLY -> room.saveDraft(UserDraft.REPLY(it.sendMode.timelineEvent.root.eventId!!, draft))
is SendMode.QUOTE -> room.saveDraft(UserDraft.QUOTE(it.sendMode.timelineEvent.root.eventId!!, draft))
is SendMode.EDIT -> room.saveDraft(UserDraft.EDIT(it.sendMode.timelineEvent.root.eventId!!, draft))
}
}
}
}
private fun handleExitSpecialMode(action: RoomDetailActions.ExitSpecialMode) {
withState { state ->
// For edit, just delete the current draft
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...) * Depending on the state the bottom toolbar will change (icons/preview/actions...)
*/ */
sealed class SendMode { sealed class SendMode(open val text: String) {
object REGULAR : SendMode() data class REGULAR(override val text: String) : SendMode(text)
data class QUOTE(val timelineEvent: TimelineEvent) : SendMode() data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class EDIT(val timelineEvent: TimelineEvent) : SendMode() data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
data class REPLY(val timelineEvent: TimelineEvent) : SendMode() data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
} }
data class RoomDetailViewState( data class RoomDetailViewState(
@ -47,7 +47,7 @@ data class RoomDetailViewState(
val timeline: Timeline? = null, val timeline: Timeline? = null,
val asyncInviter: Async<User> = Uninitialized, val asyncInviter: Async<User> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized, val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val sendMode: SendMode = SendMode.REGULAR, val sendMode: SendMode = SendMode.REGULAR(""),
val isEncrypted: Boolean = false, val isEncrypted: Boolean = false,
val tombstoneEvent: Event? = null, val tombstoneEvent: Event? = null,
val tombstoneEventHandling: Async<String> = Uninitialized, val tombstoneEventHandling: Async<String> = Uninitialized,

View File

@ -27,7 +27,8 @@ class RoomListDisplayModeFilter(private val displayMode: RoomListFragment.Displa
return false return false
} }
return when (displayMode) { return when (displayMode) {
RoomListFragment.DisplayMode.HOME -> roomSummary.notificationCount > 0 || roomSummary.membership == Membership.INVITE RoomListFragment.DisplayMode.HOME ->
roomSummary.notificationCount > 0 || roomSummary.membership == Membership.INVITE || roomSummary.userDrafts.isNotEmpty()
RoomListFragment.DisplayMode.PEOPLE -> roomSummary.isDirect && roomSummary.membership == Membership.JOIN RoomListFragment.DisplayMode.PEOPLE -> roomSummary.isDirect && roomSummary.membership == Membership.JOIN
RoomListFragment.DisplayMode.ROOMS -> !roomSummary.isDirect && roomSummary.membership == Membership.JOIN RoomListFragment.DisplayMode.ROOMS -> !roomSummary.isDirect && roomSummary.membership == Membership.JOIN
RoomListFragment.DisplayMode.FILTERED -> roomSummary.membership == Membership.JOIN RoomListFragment.DisplayMode.FILTERED -> roomSummary.membership == Membership.JOIN

View File

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

View File

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

View File

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