diff --git a/CHANGES.md b/CHANGES.md index 75b11bdbd4..63ccaea83a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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) diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt index 0ff0987dfe..28a3d40070 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt @@ -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> { + return room.getDraftsLive().asObservable() + } + } fun Room.rx(): RxRoom { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt index ec6b382f8f..92414eb768 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt @@ -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, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt index e4bf1bd32b..099deae937 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt @@ -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 = emptyList(), val membership: Membership = Membership.NONE, - val versioningState: VersioningState = VersioningState.NONE + val versioningState: VersioningState = VersioningState.NONE, + val userDrafts: List = emptyList() ) { val isVersioned: Boolean diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/DraftService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/DraftService.kt new file mode 100644 index 0000000000..c700b40a08 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/DraftService.kt @@ -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> + +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserDraft.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserDraft.kt new file mode 100644 index 0000000000..8912cc2580 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/UserDraft.kt @@ -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 + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/DraftMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/DraftMapper.kt new file mode 100644 index 0000000000..6b87951e0a --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/DraftMapper.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt index 8cb738807f..4fbe7fe04c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt @@ -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() ) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/DraftEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/DraftEntity.kt new file mode 100644 index 0000000000..9666ebd9a1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/DraftEntity.kt @@ -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" + } +} + diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt index 95308d367e..1c159b23d2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt @@ -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 = RealmList() + var tags: RealmList = RealmList(), + var userDrafts: UserDraftsEntity? = null ) : RealmObject() { private var membershipStr: String = Membership.NONE.name diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt index 1d27bf07ee..680e2eac7d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt @@ -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 diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/UserDraftsEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/UserDraftsEntity.kt new file mode 100644 index 0000000000..b713fe1c3f --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/UserDraftsEntity.kt @@ -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 = RealmList() +) : RealmObject() { + + // Link to RoomSummaryEntity + @LinkingObjects("userDrafts") + val roomSummaryEntity: RealmResults? = null + + companion object + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/UserDraftsEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/UserDraftsEntityQueries.kt new file mode 100644 index 0000000000..ae368c5850 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/UserDraftsEntityQueries.kt @@ -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 { + val query = realm.where() + if (roomId != null) { + query.equalTo(UserDraftsEntityFields.ROOM_SUMMARY_ENTITY + "." + RoomSummaryEntityFields.ROOM_ID, roomId) + } + return query +} + diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt index 492dd03543..6b2a6843f1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt @@ -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, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index 53da2d7709..e972f6a98e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -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, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/draft/DefaultDraftService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/draft/DefaultDraftService.kt new file mode 100644 index 0000000000..c5676f84c8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/draft/DefaultDraftService.kt @@ -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().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> { + 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() + } + } +} + diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt index 2c20839b26..a97d03d734 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt @@ -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() } } diff --git a/vector/build.gradle b/vector/build.gradle index 349bf7cf88..697d0f36d0 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -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" diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt index e60bc422a8..dda5f611b6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt @@ -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) : 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() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index cd1ccb01d2..b32d08ea7d 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -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(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) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 8ec133c642..593a3dfd04 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -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() - ?: return + val tombstoneContent = action.event.getClearContent().toModel() ?: 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>>>() val nonBlockingPopAlert: LiveData>>> 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()?.relatesTo?.inReplyTo?.eventId - ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId + ?: state.sendMode.timelineEvent.root.content.toModel()?.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)) } } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index d8358efe16..a47ee56500 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -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 = Uninitialized, val asyncRoomSummary: Async = Uninitialized, - val sendMode: SendMode = SendMode.REGULAR, + val sendMode: SendMode = SendMode.REGULAR(""), val isEncrypted: Boolean = false, val tombstoneEvent: Event? = null, val tombstoneEventHandling: Async = Uninitialized, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt index 2ee1f30645..f5b62e4512 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt @@ -40,6 +40,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel() { @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() { 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() { val unreadCounterBadgeView by bind(R.id.roomUnreadCounterBadgeView) val unreadIndentIndicator by bind(R.id.roomUnreadIndicator) val lastEventView by bind(R.id.roomLastEventView) + val draftView by bind(R.id.roomDraftBadge) val lastEventTimeView by bind(R.id.roomLastEventTimeView) val avatarImageView by bind(R.id.roomAvatarImageView) val rootView by bind(R.id.itemRoomLayout) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt index 015e54b368..942796961b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt @@ -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) } } diff --git a/vector/src/main/res/layout/item_room.xml b/vector/src/main/res/layout/item_room.xml index 7eb2083ecb..741bd47069 100644 --- a/vector/src/main/res/layout/item_room.xml +++ b/vector/src/main/res/layout/item_room.xml @@ -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" /> + + + tools:text="4" + tools:visibility="visible" />