Save draft of a message when exiting a room with non empty composer (#329)
This commit is contained in:
parent
c728834273
commit
36866dd24e
|
@ -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)
|
||||
|
|
|
@ -20,6 +20,7 @@ import im.vector.matrix.android.api.session.room.Room
|
|||
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
|
||||
import im.vector.matrix.android.api.session.room.model.ReadReceipt
|
||||
import im.vector.matrix.android.api.session.room.model.RoomSummary
|
||||
import im.vector.matrix.android.api.session.room.send.UserDraft
|
||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
|
@ -54,6 +55,10 @@ class RxRoom(private val room: Room) {
|
|||
return room.getEventReadReceiptsLive(eventId).asObservable()
|
||||
}
|
||||
|
||||
fun liveDrafts(): Observable<List<UserDraft>> {
|
||||
return room.getDraftsLive().asObservable()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun Room.rx(): RxRoom {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
package im.vector.matrix.android.api.session.room.model
|
||||
|
||||
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
|
||||
import im.vector.matrix.android.api.session.room.send.UserDraft
|
||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||
|
||||
/**
|
||||
|
@ -36,7 +37,8 @@ data class RoomSummary(
|
|||
val hasUnreadMessages: Boolean = false,
|
||||
val tags: List<RoomTag> = emptyList(),
|
||||
val membership: Membership = Membership.NONE,
|
||||
val versioningState: VersioningState = VersioningState.NONE
|
||||
val versioningState: VersioningState = VersioningState.NONE,
|
||||
val userDrafts: List<UserDraft> = emptyList()
|
||||
) {
|
||||
|
||||
val isVersioned: Boolean
|
||||
|
|
|
@ -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>>
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,8 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "",
|
|||
var notificationCount: Int = 0,
|
||||
var highlightCount: Int = 0,
|
||||
var hasUnreadMessages: Boolean = false,
|
||||
var tags: RealmList<RoomTagEntity> = RealmList()
|
||||
var tags: RealmList<RoomTagEntity> = RealmList(),
|
||||
var userDrafts: UserDraftsEntity? = null
|
||||
) : RealmObject() {
|
||||
|
||||
private var membershipStr: String = Membership.NONE.name
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -18,13 +18,13 @@ package im.vector.riotx.features.home.room.detail
|
|||
|
||||
import com.jaiselrahman.filepicker.model.MediaFile
|
||||
import im.vector.matrix.android.api.session.events.model.Event
|
||||
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
|
||||
import im.vector.matrix.android.api.session.room.timeline.Timeline
|
||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||
|
||||
sealed class RoomDetailActions {
|
||||
|
||||
data class SaveDraft(val draft: String) : RoomDetailActions()
|
||||
data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
|
||||
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
|
||||
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
|
||||
|
@ -35,13 +35,15 @@ sealed class RoomDetailActions {
|
|||
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailActions()
|
||||
data class NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions()
|
||||
data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions()
|
||||
data class HandleTombstoneEvent(val event: Event): RoomDetailActions()
|
||||
data class HandleTombstoneEvent(val event: Event) : RoomDetailActions()
|
||||
object AcceptInvite : RoomDetailActions()
|
||||
object RejectInvite : RoomDetailActions()
|
||||
|
||||
data class EnterEditMode(val eventId: String) : RoomDetailActions()
|
||||
data class EnterQuoteMode(val eventId: String) : RoomDetailActions()
|
||||
data class EnterReplyMode(val eventId: String) : RoomDetailActions()
|
||||
data class EnterQuoteMode(val eventId: String, val draft: String) : RoomDetailActions()
|
||||
data class EnterReplyMode(val eventId: String, val draft: String) : RoomDetailActions()
|
||||
data class ExitSpecialMode(val draft: String) : RoomDetailActions()
|
||||
|
||||
data class ResendMessage(val eventId: String) : RoomDetailActions()
|
||||
data class RemoveFailedEcho(val eventId: String) : RoomDetailActions()
|
||||
object ClearSendQueue : RoomDetailActions()
|
||||
|
|
|
@ -53,6 +53,7 @@ import com.google.android.material.snackbar.Snackbar
|
|||
import com.jaiselrahman.filepicker.activity.FilePickerActivity
|
||||
import com.jaiselrahman.filepicker.config.Configurations
|
||||
import com.jaiselrahman.filepicker.model.MediaFile
|
||||
import com.jakewharton.rxbinding3.widget.afterTextChangeEvents
|
||||
import com.otaliastudios.autocomplete.Autocomplete
|
||||
import com.otaliastudios.autocomplete.AutocompleteCallback
|
||||
import com.otaliastudios.autocomplete.CharPolicy
|
||||
|
@ -64,7 +65,6 @@ import im.vector.matrix.android.api.session.room.model.message.*
|
|||
import im.vector.matrix.android.api.session.room.send.SendState
|
||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
|
||||
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
|
||||
import im.vector.matrix.android.api.session.user.model.User
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.di.ScreenComponent
|
||||
|
@ -107,6 +107,7 @@ import im.vector.riotx.features.notifications.NotificationDrawerManager
|
|||
import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
|
||||
import im.vector.riotx.features.settings.VectorPreferences
|
||||
import im.vector.riotx.features.themes.ThemeUtils
|
||||
import io.reactivex.rxkotlin.subscribeBy
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.android.synthetic.main.fragment_room_detail.*
|
||||
import kotlinx.android.synthetic.main.merge_composer_layout.view.*
|
||||
|
@ -114,6 +115,7 @@ import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
|
|||
import org.commonmark.parser.Parser
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
|
@ -242,10 +244,10 @@ class RoomDetailFragment :
|
|||
|
||||
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode) { mode ->
|
||||
when (mode) {
|
||||
SendMode.REGULAR -> exitSpecialMode()
|
||||
is SendMode.EDIT -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_edit, true)
|
||||
is SendMode.QUOTE -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_quote, false)
|
||||
is SendMode.REPLY -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_reply, false)
|
||||
is SendMode.REGULAR -> renderRegularMode(mode.text)
|
||||
is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, mode.text)
|
||||
is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, mode.text)
|
||||
is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, mode.text)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -300,14 +302,16 @@ class RoomDetailFragment :
|
|||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun exitSpecialMode() {
|
||||
private fun renderRegularMode(text: String) {
|
||||
commandAutocompletePolicy.enabled = true
|
||||
composerLayout.collapse()
|
||||
|
||||
updateComposerText(text)
|
||||
}
|
||||
|
||||
private fun enterSpecialMode(event: TimelineEvent,
|
||||
@DrawableRes iconRes: Int,
|
||||
useText: Boolean) {
|
||||
private fun renderSpecialMode(event: TimelineEvent,
|
||||
@DrawableRes iconRes: Int,
|
||||
defaultContent: String) {
|
||||
commandAutocompletePolicy.enabled = false
|
||||
//switch to expanded bar
|
||||
composerLayout.composerRelatedMessageTitle.apply {
|
||||
|
@ -321,19 +325,20 @@ class RoomDetailFragment :
|
|||
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
|
||||
val parser = Parser.builder().build()
|
||||
val document = parser.parse(messageContent.formattedBody
|
||||
?: messageContent.body)
|
||||
?: messageContent.body)
|
||||
formattedBody = eventHtmlRenderer.render(document)
|
||||
}
|
||||
composerLayout.composerRelatedMessageContent.text = formattedBody
|
||||
?: nonFormattedBody
|
||||
composerLayout.composerRelatedMessageContent.text = formattedBody ?: nonFormattedBody
|
||||
|
||||
updateComposerText(defaultContent)
|
||||
|
||||
composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "")
|
||||
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
|
||||
|
||||
avatarRenderer.render(event.senderAvatar, event.root.senderId
|
||||
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)
|
||||
avatarRenderer.render(event.senderAvatar,
|
||||
event.root.senderId ?: "",
|
||||
event.senderName,
|
||||
composerLayout.composerRelatedMessageAvatar)
|
||||
|
||||
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
|
||||
composerLayout.expand {
|
||||
//need to do it here also when not using quick reply
|
||||
focusComposerAndShowKeyboard()
|
||||
|
@ -341,6 +346,16 @@ class RoomDetailFragment :
|
|||
focusComposerAndShowKeyboard()
|
||||
}
|
||||
|
||||
private fun updateComposerText(text: String) {
|
||||
// Do not update if this is the same text to avoid the cursor to move
|
||||
if (text != composerLayout.composerEditText.text.toString()) {
|
||||
// Ignore update to avoid saving a draft
|
||||
filterComposerTextChange = true
|
||||
composerLayout.composerEditText.setText(text)
|
||||
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
@ -360,9 +375,9 @@ class RoomDetailFragment :
|
|||
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
|
||||
REACTION_SELECT_REQUEST_CODE -> {
|
||||
val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID)
|
||||
?: return
|
||||
?: return
|
||||
val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT)
|
||||
?: return
|
||||
?: return
|
||||
//TODO check if already reacted with that?
|
||||
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId))
|
||||
}
|
||||
|
@ -397,32 +412,46 @@ class RoomDetailFragment :
|
|||
|
||||
if (vectorPreferences.swipeToReplyIsEnabled()) {
|
||||
val swipeCallback = RoomMessageTouchHelperCallback(requireContext(),
|
||||
R.drawable.ic_reply,
|
||||
object : RoomMessageTouchHelperCallback.QuickReplayHandler {
|
||||
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
|
||||
(model as? AbsMessageItem)?.informationData?.let {
|
||||
val eventId = it.eventId
|
||||
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
|
||||
}
|
||||
}
|
||||
R.drawable.ic_reply,
|
||||
object : RoomMessageTouchHelperCallback.QuickReplayHandler {
|
||||
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
|
||||
(model as? AbsMessageItem)?.informationData?.let {
|
||||
val eventId = it.eventId
|
||||
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
|
||||
return when (model) {
|
||||
is MessageFileItem,
|
||||
is MessageImageVideoItem,
|
||||
is MessageTextItem -> {
|
||||
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
})
|
||||
override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
|
||||
return when (model) {
|
||||
is MessageFileItem,
|
||||
is MessageImageVideoItem,
|
||||
is MessageTextItem -> {
|
||||
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
})
|
||||
val touchHelper = ItemTouchHelper(swipeCallback)
|
||||
touchHelper.attachToRecyclerView(recyclerView)
|
||||
}
|
||||
}
|
||||
|
||||
private var filterComposerTextChange = true
|
||||
|
||||
private fun setupComposer() {
|
||||
composerLayout.composerEditText.afterTextChangeEvents()
|
||||
.debounce(100, TimeUnit.MILLISECONDS)
|
||||
.subscribeBy {
|
||||
if (filterComposerTextChange) {
|
||||
Timber.d("Draft: ignore text update")
|
||||
filterComposerTextChange = false
|
||||
return@subscribeBy
|
||||
}
|
||||
roomDetailViewModel.process(RoomDetailActions.SaveDraft(it.editable.toString()))
|
||||
}
|
||||
.disposeOnDestroy()
|
||||
|
||||
val elevation = 6f
|
||||
val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(requireContext(), R.attr.riotx_background))
|
||||
Autocomplete.on<Command>(composerLayout.composerEditText)
|
||||
|
@ -492,8 +521,7 @@ class RoomDetailFragment :
|
|||
}
|
||||
}
|
||||
composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
|
||||
composerLayout.composerEditText.setText("")
|
||||
roomDetailViewModel.resetSendMode()
|
||||
roomDetailViewModel.process(RoomDetailActions.ExitSpecialMode(composerLayout.composerEditText.text.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -645,13 +673,11 @@ class RoomDetailFragment :
|
|||
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
|
||||
when (sendMessageResult) {
|
||||
is SendMessageResult.MessageSent -> {
|
||||
// Clear composer
|
||||
composerLayout.composerEditText.text = null
|
||||
// Nothing to do, the composer will be cleared with the draft update
|
||||
}
|
||||
is SendMessageResult.SlashCommandHandled -> {
|
||||
sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) }
|
||||
// Clear composer
|
||||
composerLayout.composerEditText.text = null
|
||||
// The composer will be cleared with the draft update
|
||||
}
|
||||
is SendMessageResult.SlashCommandError -> {
|
||||
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
|
||||
|
@ -916,10 +942,10 @@ class RoomDetailFragment :
|
|||
roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId))
|
||||
}
|
||||
is SimpleAction.Quote -> {
|
||||
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId))
|
||||
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId, composerLayout.composerEditText.text.toString()))
|
||||
}
|
||||
is SimpleAction.Reply -> {
|
||||
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId))
|
||||
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId, composerLayout.composerEditText.text.toString()))
|
||||
}
|
||||
is SimpleAction.CopyPermalink -> {
|
||||
val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId)
|
||||
|
|
|
@ -42,8 +42,9 @@ import im.vector.matrix.android.api.session.room.model.message.MessageContent
|
|||
import im.vector.matrix.android.api.session.room.model.message.MessageType
|
||||
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
|
||||
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
|
||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||
import im.vector.matrix.android.api.session.room.send.UserDraft
|
||||
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
|
||||
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
|
||||
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
|
||||
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
|
||||
import im.vector.matrix.rx.rx
|
||||
|
@ -84,6 +85,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
|
||||
private var timeline = room.createTimeline(eventId, timelineSettings)
|
||||
|
||||
// Filter to avoid infinite loop when user enter text in the composer and call SaveDraft
|
||||
private var filterDraftUpdate = false
|
||||
|
||||
// Slot to keep a pending action during permission request
|
||||
var pendingAction: RoomDetailActions? = null
|
||||
|
||||
|
@ -109,6 +113,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
observeRoomSummary()
|
||||
observeEventDisplayedActions()
|
||||
observeSummaryState()
|
||||
observeDrafts()
|
||||
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
|
||||
timeline.start()
|
||||
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
|
||||
|
@ -116,6 +121,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
|
||||
fun process(action: RoomDetailActions) {
|
||||
when (action) {
|
||||
is RoomDetailActions.SaveDraft -> handleSaveDraft(action)
|
||||
is RoomDetailActions.SendMessage -> handleSendMessage(action)
|
||||
is RoomDetailActions.SendMedia -> handleSendMedia(action)
|
||||
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
|
||||
|
@ -129,6 +135,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
is RoomDetailActions.EnterEditMode -> handleEditAction(action)
|
||||
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
|
||||
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
|
||||
is RoomDetailActions.ExitSpecialMode -> handleExitSpecialMode(action)
|
||||
is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
|
||||
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
|
||||
is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action)
|
||||
|
@ -140,9 +147,64 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a send mode to a draft and save the draft
|
||||
*/
|
||||
private fun handleSaveDraft(action: RoomDetailActions.SaveDraft) {
|
||||
// The text is changed, ignore the next update from DB
|
||||
filterDraftUpdate = true
|
||||
|
||||
withState {
|
||||
when (it.sendMode) {
|
||||
is SendMode.REGULAR -> room.saveDraft(UserDraft.REGULAR(action.draft))
|
||||
is SendMode.REPLY -> room.saveDraft(UserDraft.REPLY(it.sendMode.timelineEvent.root.eventId!!, action.draft))
|
||||
is SendMode.QUOTE -> room.saveDraft(UserDraft.QUOTE(it.sendMode.timelineEvent.root.eventId!!, action.draft))
|
||||
is SendMode.EDIT -> room.saveDraft(UserDraft.EDIT(it.sendMode.timelineEvent.root.eventId!!, action.draft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeDrafts() {
|
||||
room.rx().liveDrafts()
|
||||
.subscribe {
|
||||
Timber.d("Draft update!")
|
||||
if (filterDraftUpdate) {
|
||||
Timber.d(" --> Ignore")
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
Timber.d(" --> SetState")
|
||||
|
||||
setState {
|
||||
val draft = it.lastOrNull() ?: UserDraft.REGULAR("")
|
||||
copy(
|
||||
// Create a sendMode from a draft and retrieve the TimelineEvent
|
||||
sendMode = when (draft) {
|
||||
is UserDraft.REGULAR -> SendMode.REGULAR(draft.text)
|
||||
is UserDraft.QUOTE -> {
|
||||
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
|
||||
SendMode.QUOTE(timelineEvent, draft.text)
|
||||
}
|
||||
}
|
||||
is UserDraft.REPLY -> {
|
||||
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
|
||||
SendMode.REPLY(timelineEvent, draft.text)
|
||||
}
|
||||
}
|
||||
is UserDraft.EDIT -> {
|
||||
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
|
||||
SendMode.EDIT(timelineEvent, draft.text)
|
||||
}
|
||||
}
|
||||
} ?: SendMode.REGULAR("")
|
||||
)
|
||||
}
|
||||
}
|
||||
.disposeOnClear()
|
||||
}
|
||||
|
||||
private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) {
|
||||
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>()
|
||||
?: return
|
||||
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() ?: return
|
||||
|
||||
val roomId = tombstoneContent.replacementRoom ?: ""
|
||||
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
|
||||
|
@ -166,22 +228,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
|
||||
}
|
||||
|
||||
private fun enterEditMode(event: TimelineEvent) {
|
||||
setState {
|
||||
copy(
|
||||
sendMode = SendMode.EDIT(event)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSendMode() {
|
||||
setState {
|
||||
copy(
|
||||
sendMode = SendMode.REGULAR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val _nonBlockingPopAlert = MutableLiveData<LiveEvent<Pair<Int, List<Any>>>>()
|
||||
val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>>
|
||||
get() = _nonBlockingPopAlert
|
||||
|
@ -218,7 +264,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
|
||||
withState { state ->
|
||||
when (state.sendMode) {
|
||||
SendMode.REGULAR -> {
|
||||
is SendMode.REGULAR -> {
|
||||
val slashCommandResult = CommandParser.parseSplashCommand(action.text)
|
||||
|
||||
when (slashCommandResult) {
|
||||
|
@ -226,6 +272,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
// Send the text message to the room
|
||||
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
|
||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
||||
popDraft()
|
||||
}
|
||||
is ParsedCommand.ErrorSyntax -> {
|
||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command))
|
||||
|
@ -238,6 +285,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
}
|
||||
is ParsedCommand.Invite -> {
|
||||
handleInviteSlashCommand(slashCommandResult)
|
||||
popDraft()
|
||||
}
|
||||
is ParsedCommand.SetUserPowerLevel -> {
|
||||
// TODO
|
||||
|
@ -251,6 +299,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
vectorPreferences.setMarkdownEnabled(slashCommandResult.enable)
|
||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled(
|
||||
if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled))
|
||||
popDraft()
|
||||
}
|
||||
is ParsedCommand.UnbanUser -> {
|
||||
// TODO
|
||||
|
@ -275,9 +324,11 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
is ParsedCommand.SendEmote -> {
|
||||
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
|
||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled())
|
||||
popDraft()
|
||||
}
|
||||
is ParsedCommand.ChangeTopic -> {
|
||||
handleChangeTopicSlashCommand(slashCommandResult)
|
||||
popDraft()
|
||||
}
|
||||
is ParsedCommand.ChangeDisplayName -> {
|
||||
// TODO
|
||||
|
@ -285,11 +336,11 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
}
|
||||
}
|
||||
}
|
||||
is SendMode.EDIT -> {
|
||||
is SendMode.EDIT -> {
|
||||
|
||||
//is original event a reply?
|
||||
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId
|
||||
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
|
||||
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
|
||||
if (inReplyTo != null) {
|
||||
//TODO check if same content?
|
||||
room.getTimeLineEvent(inReplyTo)?.let {
|
||||
|
@ -298,27 +349,24 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
} else {
|
||||
val messageContent: MessageContent? =
|
||||
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
|
||||
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
||||
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
||||
val existingBody = messageContent?.body ?: ""
|
||||
if (existingBody != action.text) {
|
||||
room.editTextMessage(state.sendMode.timelineEvent.root.eventId
|
||||
?: "", messageContent?.type
|
||||
?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
|
||||
room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "",
|
||||
messageContent?.type ?: MessageType.MSGTYPE_TEXT,
|
||||
action.text,
|
||||
action.autoMarkdown)
|
||||
} else {
|
||||
Timber.w("Same message content, do not send edition")
|
||||
}
|
||||
}
|
||||
setState {
|
||||
copy(
|
||||
sendMode = SendMode.REGULAR
|
||||
)
|
||||
}
|
||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
||||
popDraft()
|
||||
}
|
||||
is SendMode.QUOTE -> {
|
||||
is SendMode.QUOTE -> {
|
||||
val messageContent: MessageContent? =
|
||||
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
|
||||
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
||||
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
||||
val textMsg = messageContent?.body
|
||||
|
||||
val finalText = legacyRiotQuoteText(textMsg, action.text)
|
||||
|
@ -333,29 +381,25 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
} else {
|
||||
room.sendFormattedTextMessage(finalText, htmlText)
|
||||
}
|
||||
setState {
|
||||
copy(
|
||||
sendMode = SendMode.REGULAR
|
||||
)
|
||||
}
|
||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
||||
popDraft()
|
||||
}
|
||||
is SendMode.REPLY -> {
|
||||
is SendMode.REPLY -> {
|
||||
state.sendMode.timelineEvent.let {
|
||||
room.replyToMessage(it, action.text, action.autoMarkdown)
|
||||
setState {
|
||||
copy(
|
||||
sendMode = SendMode.REGULAR
|
||||
)
|
||||
}
|
||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
||||
popDraft()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun popDraft() {
|
||||
filterDraftUpdate = false
|
||||
room.deleteDraft()
|
||||
}
|
||||
|
||||
private fun legacyRiotQuoteText(quotedText: String?, myText: String): String {
|
||||
val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray()
|
||||
var quotedTextMsg = StringBuilder()
|
||||
|
@ -469,27 +513,56 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||
}
|
||||
|
||||
private fun handleEditAction(action: RoomDetailActions.EnterEditMode) {
|
||||
room.getTimeLineEvent(action.eventId)?.let {
|
||||
enterEditMode(it)
|
||||
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
|
||||
timelineEvent.root.eventId?.let {
|
||||
filterDraftUpdate = false
|
||||
room.saveDraft(UserDraft.EDIT(it, timelineEvent.getTextEditableContent() ?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) {
|
||||
room.getTimeLineEvent(action.eventId)?.let {
|
||||
setState {
|
||||
copy(
|
||||
sendMode = SendMode.QUOTE(it)
|
||||
)
|
||||
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
|
||||
withState { state ->
|
||||
// Save a new draft and keep the previously entered text, if it was not an edit
|
||||
timelineEvent.root.eventId?.let {
|
||||
filterDraftUpdate = false
|
||||
if (state.sendMode is SendMode.EDIT) {
|
||||
room.saveDraft(UserDraft.QUOTE(it, ""))
|
||||
} else {
|
||||
room.saveDraft(UserDraft.QUOTE(it, action.draft))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReplyAction(action: RoomDetailActions.EnterReplyMode) {
|
||||
room.getTimeLineEvent(action.eventId)?.let {
|
||||
setState {
|
||||
copy(
|
||||
sendMode = SendMode.REPLY(it)
|
||||
)
|
||||
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
|
||||
withState { state ->
|
||||
// Save a new draft and keep the previously entered text, if it was not an edit
|
||||
timelineEvent.root.eventId?.let {
|
||||
filterDraftUpdate = false
|
||||
if (state.sendMode is SendMode.EDIT) {
|
||||
room.saveDraft(UserDraft.REPLY(it, ""))
|
||||
} else {
|
||||
room.saveDraft(UserDraft.REPLY(it, action.draft))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExitSpecialMode(action: RoomDetailActions.ExitSpecialMode) {
|
||||
withState { state ->
|
||||
// For edit, just delete the current draft
|
||||
filterDraftUpdate = false
|
||||
|
||||
if (state.sendMode is SendMode.EDIT) {
|
||||
room.deleteDraft()
|
||||
} else {
|
||||
// Save a new draft and keep the previously entered text
|
||||
room.saveDraft(UserDraft.REGULAR(action.draft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,11 +34,11 @@ import im.vector.matrix.android.api.session.user.model.User
|
|||
*
|
||||
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
|
||||
*/
|
||||
sealed class SendMode {
|
||||
object REGULAR : SendMode()
|
||||
data class QUOTE(val timelineEvent: TimelineEvent) : SendMode()
|
||||
data class EDIT(val timelineEvent: TimelineEvent) : SendMode()
|
||||
data class REPLY(val timelineEvent: TimelineEvent) : SendMode()
|
||||
sealed class SendMode(open val text: String) {
|
||||
data class REGULAR(override val text: String) : SendMode(text)
|
||||
data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
|
||||
data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
|
||||
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
|
||||
}
|
||||
|
||||
data class RoomDetailViewState(
|
||||
|
@ -47,7 +47,7 @@ data class RoomDetailViewState(
|
|||
val timeline: Timeline? = null,
|
||||
val asyncInviter: Async<User> = Uninitialized,
|
||||
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
|
||||
val sendMode: SendMode = SendMode.REGULAR,
|
||||
val sendMode: SendMode = SendMode.REGULAR(""),
|
||||
val isEncrypted: Boolean = false,
|
||||
val tombstoneEvent: Event? = null,
|
||||
val tombstoneEventHandling: Async<String> = Uninitialized,
|
||||
|
|
|
@ -40,6 +40,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
|
|||
@EpoxyAttribute var avatarUrl: String? = null
|
||||
@EpoxyAttribute var unreadNotificationCount: Int = 0
|
||||
@EpoxyAttribute var hasUnreadMessage: Boolean = false
|
||||
@EpoxyAttribute var hasDraft: Boolean = false
|
||||
@EpoxyAttribute var showHighlighted: Boolean = false
|
||||
@EpoxyAttribute var listener: (() -> Unit)? = null
|
||||
|
||||
|
@ -52,6 +53,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
|
|||
holder.lastEventView.text = lastFormattedEvent
|
||||
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
|
||||
holder.unreadIndentIndicator.isVisible = hasUnreadMessage
|
||||
holder.draftView.isVisible = hasDraft
|
||||
avatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
|
||||
}
|
||||
|
||||
|
@ -60,6 +62,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
|
|||
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomUnreadCounterBadgeView)
|
||||
val unreadIndentIndicator by bind<View>(R.id.roomUnreadIndicator)
|
||||
val lastEventView by bind<TextView>(R.id.roomLastEventView)
|
||||
val draftView by bind<ImageView>(R.id.roomDraftBadge)
|
||||
val lastEventTimeView by bind<TextView>(R.id.roomLastEventTimeView)
|
||||
val avatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
|
||||
val rootView by bind<ViewGroup>(R.id.itemRoomLayout)
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
||||
|
|
|
@ -56,13 +56,27 @@
|
|||
android:textSize="15sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintEnd_toStartOf="@+id/roomUnreadCounterBadgeView"
|
||||
app:layout_constraintEnd_toStartOf="@+id/roomDraftBadge"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toEndOf="@id/roomAvatarImageView"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="@sample/matrix.json/data/displayName" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/roomDraftBadge"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:layout_marginRight="4dp"
|
||||
android:src="@drawable/ic_edit"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/roomNameView"
|
||||
app:layout_constraintEnd_toStartOf="@+id/roomUnreadCounterBadgeView"
|
||||
app:layout_constraintStart_toEndOf="@+id/roomNameView"
|
||||
app:layout_constraintTop_toTopOf="@+id/roomNameView"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
|
||||
android:id="@+id/roomUnreadCounterBadgeView"
|
||||
android:layout_width="wrap_content"
|
||||
|
@ -76,12 +90,14 @@
|
|||
android:paddingRight="4dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="10sp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/roomNameView"
|
||||
app:layout_constraintEnd_toStartOf="@+id/roomLastEventTimeView"
|
||||
app:layout_constraintStart_toEndOf="@+id/roomNameView"
|
||||
app:layout_constraintStart_toEndOf="@+id/roomDraftBadge"
|
||||
app:layout_constraintTop_toTopOf="@+id/roomNameView"
|
||||
tools:background="@drawable/bg_unread_highlight"
|
||||
tools:text="4" />
|
||||
tools:text="4"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/roomLastEventTimeView"
|
||||
|
|
Loading…
Reference in New Issue