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:
|
Features:
|
||||||
-
|
- Save draft of a message when exiting a room with non empty composer (#329)
|
||||||
|
|
||||||
Improvements:
|
Improvements:
|
||||||
- Add unread indent on room list (#485)
|
- Add unread indent on room list (#485)
|
||||||
|
@ -20,6 +20,7 @@ import im.vector.matrix.android.api.session.room.Room
|
|||||||
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
|
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
|
||||||
import im.vector.matrix.android.api.session.room.model.ReadReceipt
|
import im.vector.matrix.android.api.session.room.model.ReadReceipt
|
||||||
import im.vector.matrix.android.api.session.room.model.RoomSummary
|
import im.vector.matrix.android.api.session.room.model.RoomSummary
|
||||||
|
import im.vector.matrix.android.api.session.room.send.UserDraft
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||||
import io.reactivex.Observable
|
import io.reactivex.Observable
|
||||||
import io.reactivex.Single
|
import io.reactivex.Single
|
||||||
@ -54,6 +55,10 @@ class RxRoom(private val room: Room) {
|
|||||||
return room.getEventReadReceiptsLive(eventId).asObservable()
|
return room.getEventReadReceiptsLive(eventId).asObservable()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun liveDrafts(): Observable<List<UserDraft>> {
|
||||||
|
return room.getDraftsLive().asObservable()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Room.rx(): RxRoom {
|
fun Room.rx(): RxRoom {
|
||||||
|
@ -22,6 +22,7 @@ import im.vector.matrix.android.api.session.room.members.MembershipService
|
|||||||
import im.vector.matrix.android.api.session.room.model.RoomSummary
|
import im.vector.matrix.android.api.session.room.model.RoomSummary
|
||||||
import im.vector.matrix.android.api.session.room.model.relation.RelationService
|
import im.vector.matrix.android.api.session.room.model.relation.RelationService
|
||||||
import im.vector.matrix.android.api.session.room.read.ReadService
|
import im.vector.matrix.android.api.session.room.read.ReadService
|
||||||
|
import im.vector.matrix.android.api.session.room.send.DraftService
|
||||||
import im.vector.matrix.android.api.session.room.send.SendService
|
import im.vector.matrix.android.api.session.room.send.SendService
|
||||||
import im.vector.matrix.android.api.session.room.state.StateService
|
import im.vector.matrix.android.api.session.room.state.StateService
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineService
|
import im.vector.matrix.android.api.session.room.timeline.TimelineService
|
||||||
@ -32,6 +33,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineService
|
|||||||
interface Room :
|
interface Room :
|
||||||
TimelineService,
|
TimelineService,
|
||||||
SendService,
|
SendService,
|
||||||
|
DraftService,
|
||||||
ReadService,
|
ReadService,
|
||||||
MembershipService,
|
MembershipService,
|
||||||
StateService,
|
StateService,
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
package im.vector.matrix.android.api.session.room.model
|
package im.vector.matrix.android.api.session.room.model
|
||||||
|
|
||||||
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
|
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
|
||||||
|
import im.vector.matrix.android.api.session.room.send.UserDraft
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,7 +37,8 @@ data class RoomSummary(
|
|||||||
val hasUnreadMessages: Boolean = false,
|
val hasUnreadMessages: Boolean = false,
|
||||||
val tags: List<RoomTag> = emptyList(),
|
val tags: List<RoomTag> = emptyList(),
|
||||||
val membership: Membership = Membership.NONE,
|
val membership: Membership = Membership.NONE,
|
||||||
val versioningState: VersioningState = VersioningState.NONE
|
val versioningState: VersioningState = VersioningState.NONE,
|
||||||
|
val userDrafts: List<UserDraft> = emptyList()
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val isVersioned: Boolean
|
val isVersioned: Boolean
|
||||||
|
@ -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.api.session.room.model.tag.RoomTag
|
||||||
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
|
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
|
||||||
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
|
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
|
||||||
import java.util.UUID
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
internal class RoomSummaryMapper @Inject constructor(
|
internal class RoomSummaryMapper @Inject constructor(
|
||||||
@ -67,7 +67,8 @@ internal class RoomSummaryMapper @Inject constructor(
|
|||||||
hasUnreadMessages = roomSummaryEntity.hasUnreadMessages,
|
hasUnreadMessages = roomSummaryEntity.hasUnreadMessages,
|
||||||
tags = tags,
|
tags = tags,
|
||||||
membership = roomSummaryEntity.membership,
|
membership = roomSummaryEntity.membership,
|
||||||
versioningState = roomSummaryEntity.versioningState
|
versioningState = roomSummaryEntity.versioningState,
|
||||||
|
userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 notificationCount: Int = 0,
|
||||||
var highlightCount: Int = 0,
|
var highlightCount: Int = 0,
|
||||||
var hasUnreadMessages: Boolean = false,
|
var hasUnreadMessages: Boolean = false,
|
||||||
var tags: RealmList<RoomTagEntity> = RealmList()
|
var tags: RealmList<RoomTagEntity> = RealmList(),
|
||||||
|
var userDrafts: UserDraftsEntity? = null
|
||||||
) : RealmObject() {
|
) : RealmObject() {
|
||||||
|
|
||||||
private var membershipStr: String = Membership.NONE.name
|
private var membershipStr: String = Membership.NONE.name
|
||||||
|
@ -43,6 +43,8 @@ import io.realm.annotations.RealmModule
|
|||||||
PushConditionEntity::class,
|
PushConditionEntity::class,
|
||||||
PusherEntity::class,
|
PusherEntity::class,
|
||||||
PusherDataEntity::class,
|
PusherDataEntity::class,
|
||||||
ReadReceiptsSummaryEntity::class
|
ReadReceiptsSummaryEntity::class,
|
||||||
|
UserDraftsEntity::class,
|
||||||
|
DraftEntity::class
|
||||||
])
|
])
|
||||||
internal class SessionRealmModule
|
internal class SessionRealmModule
|
||||||
|
@ -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.RoomSummary
|
||||||
import im.vector.matrix.android.api.session.room.model.relation.RelationService
|
import im.vector.matrix.android.api.session.room.model.relation.RelationService
|
||||||
import im.vector.matrix.android.api.session.room.read.ReadService
|
import im.vector.matrix.android.api.session.room.read.ReadService
|
||||||
|
import im.vector.matrix.android.api.session.room.send.DraftService
|
||||||
import im.vector.matrix.android.api.session.room.send.SendService
|
import im.vector.matrix.android.api.session.room.send.SendService
|
||||||
import im.vector.matrix.android.api.session.room.state.StateService
|
import im.vector.matrix.android.api.session.room.state.StateService
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineService
|
import im.vector.matrix.android.api.session.room.timeline.TimelineService
|
||||||
@ -40,6 +41,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
|
|||||||
private val roomSummaryMapper: RoomSummaryMapper,
|
private val roomSummaryMapper: RoomSummaryMapper,
|
||||||
private val timelineService: TimelineService,
|
private val timelineService: TimelineService,
|
||||||
private val sendService: SendService,
|
private val sendService: SendService,
|
||||||
|
private val draftService: DraftService,
|
||||||
private val stateService: StateService,
|
private val stateService: StateService,
|
||||||
private val readService: ReadService,
|
private val readService: ReadService,
|
||||||
private val cryptoService: CryptoService,
|
private val cryptoService: CryptoService,
|
||||||
@ -48,6 +50,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
|
|||||||
) : Room,
|
) : Room,
|
||||||
TimelineService by timelineService,
|
TimelineService by timelineService,
|
||||||
SendService by sendService,
|
SendService by sendService,
|
||||||
|
DraftService by draftService,
|
||||||
StateService by stateService,
|
StateService by stateService,
|
||||||
ReadService by readService,
|
ReadService by readService,
|
||||||
RelationService by relationService,
|
RelationService by relationService,
|
||||||
|
@ -20,6 +20,7 @@ import com.zhuinden.monarchy.Monarchy
|
|||||||
import im.vector.matrix.android.api.session.crypto.CryptoService
|
import im.vector.matrix.android.api.session.crypto.CryptoService
|
||||||
import im.vector.matrix.android.api.session.room.Room
|
import im.vector.matrix.android.api.session.room.Room
|
||||||
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper
|
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper
|
||||||
|
import im.vector.matrix.android.internal.session.room.draft.DefaultDraftService
|
||||||
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
|
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
|
||||||
import im.vector.matrix.android.internal.session.room.read.DefaultReadService
|
import im.vector.matrix.android.internal.session.room.read.DefaultReadService
|
||||||
import im.vector.matrix.android.internal.session.room.relation.DefaultRelationService
|
import im.vector.matrix.android.internal.session.room.relation.DefaultRelationService
|
||||||
@ -38,6 +39,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
|
|||||||
private val cryptoService: CryptoService,
|
private val cryptoService: CryptoService,
|
||||||
private val timelineServiceFactory: DefaultTimelineService.Factory,
|
private val timelineServiceFactory: DefaultTimelineService.Factory,
|
||||||
private val sendServiceFactory: DefaultSendService.Factory,
|
private val sendServiceFactory: DefaultSendService.Factory,
|
||||||
|
private val draftServiceFactory: DefaultDraftService.Factory,
|
||||||
private val stateServiceFactory: DefaultStateService.Factory,
|
private val stateServiceFactory: DefaultStateService.Factory,
|
||||||
private val readServiceFactory: DefaultReadService.Factory,
|
private val readServiceFactory: DefaultReadService.Factory,
|
||||||
private val relationServiceFactory: DefaultRelationService.Factory,
|
private val relationServiceFactory: DefaultRelationService.Factory,
|
||||||
@ -51,6 +53,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
|
|||||||
roomSummaryMapper,
|
roomSummaryMapper,
|
||||||
timelineServiceFactory.create(roomId),
|
timelineServiceFactory.create(roomId),
|
||||||
sendServiceFactory.create(roomId),
|
sendServiceFactory.create(roomId),
|
||||||
|
draftServiceFactory.create(roomId),
|
||||||
stateServiceFactory.create(roomId),
|
stateServiceFactory.create(roomId),
|
||||||
readServiceFactory.create(roomId),
|
readServiceFactory.create(roomId),
|
||||||
cryptoService,
|
cryptoService,
|
||||||
|
@ -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
|
package im.vector.matrix.android.internal.session.room.send
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.lifecycle.Transformations
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.*
|
||||||
import androidx.work.Operation
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import com.squareup.inject.assisted.Assisted
|
import com.squareup.inject.assisted.Assisted
|
||||||
import com.squareup.inject.assisted.AssistedInject
|
import com.squareup.inject.assisted.AssistedInject
|
||||||
import com.zhuinden.monarchy.Monarchy
|
import com.zhuinden.monarchy.Monarchy
|
||||||
|
import im.vector.matrix.android.BuildConfig
|
||||||
import im.vector.matrix.android.api.auth.data.Credentials
|
import im.vector.matrix.android.api.auth.data.Credentials
|
||||||
import im.vector.matrix.android.api.session.content.ContentAttachmentData
|
import im.vector.matrix.android.api.session.content.ContentAttachmentData
|
||||||
import im.vector.matrix.android.api.session.crypto.CryptoService
|
import im.vector.matrix.android.api.session.crypto.CryptoService
|
||||||
import im.vector.matrix.android.api.session.events.model.Event
|
import im.vector.matrix.android.api.session.events.model.*
|
||||||
import im.vector.matrix.android.api.session.events.model.EventType
|
|
||||||
import im.vector.matrix.android.api.session.events.model.isImageMessage
|
|
||||||
import im.vector.matrix.android.api.session.events.model.isTextMessage
|
|
||||||
import im.vector.matrix.android.api.session.events.model.toModel
|
|
||||||
import im.vector.matrix.android.api.session.room.model.message.MessageContent
|
import im.vector.matrix.android.api.session.room.model.message.MessageContent
|
||||||
import im.vector.matrix.android.api.session.room.model.message.MessageType
|
import im.vector.matrix.android.api.session.room.model.message.MessageType
|
||||||
import im.vector.matrix.android.api.session.room.send.SendService
|
import im.vector.matrix.android.api.session.room.send.SendService
|
||||||
import im.vector.matrix.android.api.session.room.send.SendState
|
import im.vector.matrix.android.api.session.room.send.SendState
|
||||||
|
import im.vector.matrix.android.api.session.room.send.UserDraft
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||||
import im.vector.matrix.android.api.util.Cancelable
|
import im.vector.matrix.android.api.util.Cancelable
|
||||||
import im.vector.matrix.android.api.util.CancelableBag
|
import im.vector.matrix.android.api.util.CancelableBag
|
||||||
|
import im.vector.matrix.android.internal.database.RealmLiveData
|
||||||
|
import im.vector.matrix.android.internal.database.mapper.DraftMapper
|
||||||
import im.vector.matrix.android.internal.database.mapper.asDomain
|
import im.vector.matrix.android.internal.database.mapper.asDomain
|
||||||
import im.vector.matrix.android.internal.database.model.EventEntity
|
import im.vector.matrix.android.internal.database.model.*
|
||||||
import im.vector.matrix.android.internal.database.model.RoomEntity
|
|
||||||
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
|
|
||||||
import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates
|
import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates
|
||||||
import im.vector.matrix.android.internal.database.query.where
|
import im.vector.matrix.android.internal.database.query.where
|
||||||
import im.vector.matrix.android.internal.session.content.UploadContentWorker
|
import im.vector.matrix.android.internal.session.content.UploadContentWorker
|
||||||
@ -75,6 +71,7 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor()
|
private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable {
|
override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable {
|
||||||
val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also {
|
val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also {
|
||||||
saveLocalEcho(it)
|
saveLocalEcho(it)
|
||||||
@ -165,12 +162,10 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private
|
|||||||
|
|
||||||
override fun deleteFailedEcho(localEcho: TimelineEvent) {
|
override fun deleteFailedEcho(localEcho: TimelineEvent) {
|
||||||
monarchy.writeAsync { realm ->
|
monarchy.writeAsync { realm ->
|
||||||
TimelineEventEntity.where(realm, eventId = localEcho.root.eventId
|
TimelineEventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.let {
|
||||||
?: "").findFirst()?.let {
|
|
||||||
it.deleteFromRealm()
|
it.deleteFromRealm()
|
||||||
}
|
}
|
||||||
EventEntity.where(realm, eventId = localEcho.root.eventId
|
EventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.let {
|
||||||
?: "").findFirst()?.let {
|
|
||||||
it.deleteFromRealm()
|
it.deleteFromRealm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -252,8 +252,9 @@ dependencies {
|
|||||||
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
|
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
|
||||||
implementation 'com.jakewharton.rxrelay2:rxrelay:2.1.0'
|
implementation 'com.jakewharton.rxrelay2:rxrelay:2.1.0'
|
||||||
// RXBinding
|
// RXBinding
|
||||||
implementation 'com.jakewharton.rxbinding3:rxbinding:3.0.0-alpha2'
|
implementation 'com.jakewharton.rxbinding3:rxbinding:3.0.0'
|
||||||
implementation 'com.jakewharton.rxbinding3:rxbinding-appcompat:3.0.0-alpha2'
|
implementation 'com.jakewharton.rxbinding3:rxbinding-appcompat:3.0.0'
|
||||||
|
implementation 'com.jakewharton.rxbinding3:rxbinding-material:3.0.0'
|
||||||
|
|
||||||
implementation("com.airbnb.android:epoxy:$epoxy_version")
|
implementation("com.airbnb.android:epoxy:$epoxy_version")
|
||||||
kapt "com.airbnb.android:epoxy-processor:$epoxy_version"
|
kapt "com.airbnb.android:epoxy-processor:$epoxy_version"
|
||||||
|
@ -18,13 +18,13 @@ package im.vector.riotx.features.home.room.detail
|
|||||||
|
|
||||||
import com.jaiselrahman.filepicker.model.MediaFile
|
import com.jaiselrahman.filepicker.model.MediaFile
|
||||||
import im.vector.matrix.android.api.session.events.model.Event
|
import im.vector.matrix.android.api.session.events.model.Event
|
||||||
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
|
|
||||||
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
|
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
|
||||||
import im.vector.matrix.android.api.session.room.timeline.Timeline
|
import im.vector.matrix.android.api.session.room.timeline.Timeline
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||||
|
|
||||||
sealed class RoomDetailActions {
|
sealed class RoomDetailActions {
|
||||||
|
|
||||||
|
data class SaveDraft(val draft: String) : RoomDetailActions()
|
||||||
data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
|
data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
|
||||||
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
|
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
|
||||||
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
|
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
|
||||||
@ -35,13 +35,15 @@ sealed class RoomDetailActions {
|
|||||||
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : 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 NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions()
|
||||||
data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : 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 AcceptInvite : RoomDetailActions()
|
||||||
object RejectInvite : RoomDetailActions()
|
object RejectInvite : RoomDetailActions()
|
||||||
|
|
||||||
data class EnterEditMode(val eventId: String) : RoomDetailActions()
|
data class EnterEditMode(val eventId: String) : RoomDetailActions()
|
||||||
data class EnterQuoteMode(val eventId: String) : RoomDetailActions()
|
data class EnterQuoteMode(val eventId: String, val draft: String) : RoomDetailActions()
|
||||||
data class EnterReplyMode(val eventId: String) : RoomDetailActions()
|
data class EnterReplyMode(val eventId: String, val draft: String) : RoomDetailActions()
|
||||||
|
data class ExitSpecialMode(val draft: String) : RoomDetailActions()
|
||||||
|
|
||||||
data class ResendMessage(val eventId: String) : RoomDetailActions()
|
data class ResendMessage(val eventId: String) : RoomDetailActions()
|
||||||
data class RemoveFailedEcho(val eventId: String) : RoomDetailActions()
|
data class RemoveFailedEcho(val eventId: String) : RoomDetailActions()
|
||||||
object ClearSendQueue : RoomDetailActions()
|
object ClearSendQueue : RoomDetailActions()
|
||||||
|
@ -53,6 +53,7 @@ import com.google.android.material.snackbar.Snackbar
|
|||||||
import com.jaiselrahman.filepicker.activity.FilePickerActivity
|
import com.jaiselrahman.filepicker.activity.FilePickerActivity
|
||||||
import com.jaiselrahman.filepicker.config.Configurations
|
import com.jaiselrahman.filepicker.config.Configurations
|
||||||
import com.jaiselrahman.filepicker.model.MediaFile
|
import com.jaiselrahman.filepicker.model.MediaFile
|
||||||
|
import com.jakewharton.rxbinding3.widget.afterTextChangeEvents
|
||||||
import com.otaliastudios.autocomplete.Autocomplete
|
import com.otaliastudios.autocomplete.Autocomplete
|
||||||
import com.otaliastudios.autocomplete.AutocompleteCallback
|
import com.otaliastudios.autocomplete.AutocompleteCallback
|
||||||
import com.otaliastudios.autocomplete.CharPolicy
|
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.send.SendState
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||||
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
|
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
|
||||||
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
|
|
||||||
import im.vector.matrix.android.api.session.user.model.User
|
import im.vector.matrix.android.api.session.user.model.User
|
||||||
import im.vector.riotx.R
|
import im.vector.riotx.R
|
||||||
import im.vector.riotx.core.di.ScreenComponent
|
import im.vector.riotx.core.di.ScreenComponent
|
||||||
@ -107,6 +107,7 @@ import im.vector.riotx.features.notifications.NotificationDrawerManager
|
|||||||
import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
|
import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
|
||||||
import im.vector.riotx.features.settings.VectorPreferences
|
import im.vector.riotx.features.settings.VectorPreferences
|
||||||
import im.vector.riotx.features.themes.ThemeUtils
|
import im.vector.riotx.features.themes.ThemeUtils
|
||||||
|
import io.reactivex.rxkotlin.subscribeBy
|
||||||
import kotlinx.android.parcel.Parcelize
|
import kotlinx.android.parcel.Parcelize
|
||||||
import kotlinx.android.synthetic.main.fragment_room_detail.*
|
import kotlinx.android.synthetic.main.fragment_room_detail.*
|
||||||
import kotlinx.android.synthetic.main.merge_composer_layout.view.*
|
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 org.commonmark.parser.Parser
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
@ -242,10 +244,10 @@ class RoomDetailFragment :
|
|||||||
|
|
||||||
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode) { mode ->
|
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode) { mode ->
|
||||||
when (mode) {
|
when (mode) {
|
||||||
SendMode.REGULAR -> exitSpecialMode()
|
is SendMode.REGULAR -> renderRegularMode(mode.text)
|
||||||
is SendMode.EDIT -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_edit, true)
|
is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, mode.text)
|
||||||
is SendMode.QUOTE -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_quote, false)
|
is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, mode.text)
|
||||||
is SendMode.REPLY -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_reply, false)
|
is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, mode.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,14 +302,16 @@ class RoomDetailFragment :
|
|||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun exitSpecialMode() {
|
private fun renderRegularMode(text: String) {
|
||||||
commandAutocompletePolicy.enabled = true
|
commandAutocompletePolicy.enabled = true
|
||||||
composerLayout.collapse()
|
composerLayout.collapse()
|
||||||
|
|
||||||
|
updateComposerText(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enterSpecialMode(event: TimelineEvent,
|
private fun renderSpecialMode(event: TimelineEvent,
|
||||||
@DrawableRes iconRes: Int,
|
@DrawableRes iconRes: Int,
|
||||||
useText: Boolean) {
|
defaultContent: String) {
|
||||||
commandAutocompletePolicy.enabled = false
|
commandAutocompletePolicy.enabled = false
|
||||||
//switch to expanded bar
|
//switch to expanded bar
|
||||||
composerLayout.composerRelatedMessageTitle.apply {
|
composerLayout.composerRelatedMessageTitle.apply {
|
||||||
@ -321,19 +325,20 @@ class RoomDetailFragment :
|
|||||||
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
|
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
|
||||||
val parser = Parser.builder().build()
|
val parser = Parser.builder().build()
|
||||||
val document = parser.parse(messageContent.formattedBody
|
val document = parser.parse(messageContent.formattedBody
|
||||||
?: messageContent.body)
|
?: messageContent.body)
|
||||||
formattedBody = eventHtmlRenderer.render(document)
|
formattedBody = eventHtmlRenderer.render(document)
|
||||||
}
|
}
|
||||||
composerLayout.composerRelatedMessageContent.text = formattedBody
|
composerLayout.composerRelatedMessageContent.text = formattedBody ?: nonFormattedBody
|
||||||
?: nonFormattedBody
|
|
||||||
|
updateComposerText(defaultContent)
|
||||||
|
|
||||||
composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "")
|
|
||||||
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
|
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
|
||||||
|
|
||||||
avatarRenderer.render(event.senderAvatar, event.root.senderId
|
avatarRenderer.render(event.senderAvatar,
|
||||||
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)
|
event.root.senderId ?: "",
|
||||||
|
event.senderName,
|
||||||
|
composerLayout.composerRelatedMessageAvatar)
|
||||||
|
|
||||||
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
|
|
||||||
composerLayout.expand {
|
composerLayout.expand {
|
||||||
//need to do it here also when not using quick reply
|
//need to do it here also when not using quick reply
|
||||||
focusComposerAndShowKeyboard()
|
focusComposerAndShowKeyboard()
|
||||||
@ -341,6 +346,16 @@ class RoomDetailFragment :
|
|||||||
focusComposerAndShowKeyboard()
|
focusComposerAndShowKeyboard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateComposerText(text: String) {
|
||||||
|
// Do not update if this is the same text to avoid the cursor to move
|
||||||
|
if (text != composerLayout.composerEditText.text.toString()) {
|
||||||
|
// Ignore update to avoid saving a draft
|
||||||
|
filterComposerTextChange = true
|
||||||
|
composerLayout.composerEditText.setText(text)
|
||||||
|
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
@ -360,9 +375,9 @@ class RoomDetailFragment :
|
|||||||
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
|
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
|
||||||
REACTION_SELECT_REQUEST_CODE -> {
|
REACTION_SELECT_REQUEST_CODE -> {
|
||||||
val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID)
|
val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID)
|
||||||
?: return
|
?: return
|
||||||
val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT)
|
val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT)
|
||||||
?: return
|
?: return
|
||||||
//TODO check if already reacted with that?
|
//TODO check if already reacted with that?
|
||||||
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId))
|
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId))
|
||||||
}
|
}
|
||||||
@ -397,32 +412,46 @@ class RoomDetailFragment :
|
|||||||
|
|
||||||
if (vectorPreferences.swipeToReplyIsEnabled()) {
|
if (vectorPreferences.swipeToReplyIsEnabled()) {
|
||||||
val swipeCallback = RoomMessageTouchHelperCallback(requireContext(),
|
val swipeCallback = RoomMessageTouchHelperCallback(requireContext(),
|
||||||
R.drawable.ic_reply,
|
R.drawable.ic_reply,
|
||||||
object : RoomMessageTouchHelperCallback.QuickReplayHandler {
|
object : RoomMessageTouchHelperCallback.QuickReplayHandler {
|
||||||
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
|
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
|
||||||
(model as? AbsMessageItem)?.informationData?.let {
|
(model as? AbsMessageItem)?.informationData?.let {
|
||||||
val eventId = it.eventId
|
val eventId = it.eventId
|
||||||
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
|
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
|
override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
|
||||||
return when (model) {
|
return when (model) {
|
||||||
is MessageFileItem,
|
is MessageFileItem,
|
||||||
is MessageImageVideoItem,
|
is MessageImageVideoItem,
|
||||||
is MessageTextItem -> {
|
is MessageTextItem -> {
|
||||||
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
|
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
val touchHelper = ItemTouchHelper(swipeCallback)
|
val touchHelper = ItemTouchHelper(swipeCallback)
|
||||||
touchHelper.attachToRecyclerView(recyclerView)
|
touchHelper.attachToRecyclerView(recyclerView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var filterComposerTextChange = true
|
||||||
|
|
||||||
private fun setupComposer() {
|
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 elevation = 6f
|
||||||
val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(requireContext(), R.attr.riotx_background))
|
val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(requireContext(), R.attr.riotx_background))
|
||||||
Autocomplete.on<Command>(composerLayout.composerEditText)
|
Autocomplete.on<Command>(composerLayout.composerEditText)
|
||||||
@ -492,8 +521,7 @@ class RoomDetailFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
|
composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
|
||||||
composerLayout.composerEditText.setText("")
|
roomDetailViewModel.process(RoomDetailActions.ExitSpecialMode(composerLayout.composerEditText.text.toString()))
|
||||||
roomDetailViewModel.resetSendMode()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -645,13 +673,11 @@ class RoomDetailFragment :
|
|||||||
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
|
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
|
||||||
when (sendMessageResult) {
|
when (sendMessageResult) {
|
||||||
is SendMessageResult.MessageSent -> {
|
is SendMessageResult.MessageSent -> {
|
||||||
// Clear composer
|
// Nothing to do, the composer will be cleared with the draft update
|
||||||
composerLayout.composerEditText.text = null
|
|
||||||
}
|
}
|
||||||
is SendMessageResult.SlashCommandHandled -> {
|
is SendMessageResult.SlashCommandHandled -> {
|
||||||
sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) }
|
sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) }
|
||||||
// Clear composer
|
// The composer will be cleared with the draft update
|
||||||
composerLayout.composerEditText.text = null
|
|
||||||
}
|
}
|
||||||
is SendMessageResult.SlashCommandError -> {
|
is SendMessageResult.SlashCommandError -> {
|
||||||
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
|
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
|
||||||
@ -916,10 +942,10 @@ class RoomDetailFragment :
|
|||||||
roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId))
|
roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId))
|
||||||
}
|
}
|
||||||
is SimpleAction.Quote -> {
|
is SimpleAction.Quote -> {
|
||||||
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId))
|
roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId, composerLayout.composerEditText.text.toString()))
|
||||||
}
|
}
|
||||||
is SimpleAction.Reply -> {
|
is SimpleAction.Reply -> {
|
||||||
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId))
|
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId, composerLayout.composerEditText.text.toString()))
|
||||||
}
|
}
|
||||||
is SimpleAction.CopyPermalink -> {
|
is SimpleAction.CopyPermalink -> {
|
||||||
val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId)
|
val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId)
|
||||||
|
@ -42,8 +42,9 @@ import im.vector.matrix.android.api.session.room.model.message.MessageContent
|
|||||||
import im.vector.matrix.android.api.session.room.model.message.MessageType
|
import im.vector.matrix.android.api.session.room.model.message.MessageType
|
||||||
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
|
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
|
||||||
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
|
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
import im.vector.matrix.android.api.session.room.send.UserDraft
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
|
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
|
||||||
|
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
|
||||||
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
|
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
|
||||||
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
|
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
|
||||||
import im.vector.matrix.rx.rx
|
import im.vector.matrix.rx.rx
|
||||||
@ -84,6 +85,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
|
|
||||||
private var timeline = room.createTimeline(eventId, timelineSettings)
|
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
|
// Slot to keep a pending action during permission request
|
||||||
var pendingAction: RoomDetailActions? = null
|
var pendingAction: RoomDetailActions? = null
|
||||||
|
|
||||||
@ -109,6 +113,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
observeRoomSummary()
|
observeRoomSummary()
|
||||||
observeEventDisplayedActions()
|
observeEventDisplayedActions()
|
||||||
observeSummaryState()
|
observeSummaryState()
|
||||||
|
observeDrafts()
|
||||||
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
|
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
|
||||||
timeline.start()
|
timeline.start()
|
||||||
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
|
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
|
||||||
@ -116,6 +121,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
|
|
||||||
fun process(action: RoomDetailActions) {
|
fun process(action: RoomDetailActions) {
|
||||||
when (action) {
|
when (action) {
|
||||||
|
is RoomDetailActions.SaveDraft -> handleSaveDraft(action)
|
||||||
is RoomDetailActions.SendMessage -> handleSendMessage(action)
|
is RoomDetailActions.SendMessage -> handleSendMessage(action)
|
||||||
is RoomDetailActions.SendMedia -> handleSendMedia(action)
|
is RoomDetailActions.SendMedia -> handleSendMedia(action)
|
||||||
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
|
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
|
||||||
@ -129,6 +135,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
is RoomDetailActions.EnterEditMode -> handleEditAction(action)
|
is RoomDetailActions.EnterEditMode -> handleEditAction(action)
|
||||||
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
|
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
|
||||||
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
|
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
|
||||||
|
is RoomDetailActions.ExitSpecialMode -> handleExitSpecialMode(action)
|
||||||
is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
|
is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
|
||||||
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
|
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
|
||||||
is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action)
|
is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action)
|
||||||
@ -140,9 +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) {
|
private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) {
|
||||||
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>()
|
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() ?: return
|
||||||
?: return
|
|
||||||
|
|
||||||
val roomId = tombstoneContent.replacementRoom ?: ""
|
val roomId = tombstoneContent.replacementRoom ?: ""
|
||||||
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
|
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
|
||||||
@ -166,22 +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>>>>()
|
private val _nonBlockingPopAlert = MutableLiveData<LiveEvent<Pair<Int, List<Any>>>>()
|
||||||
val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>>
|
val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>>
|
||||||
get() = _nonBlockingPopAlert
|
get() = _nonBlockingPopAlert
|
||||||
@ -218,7 +264,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
|
private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
|
||||||
withState { state ->
|
withState { state ->
|
||||||
when (state.sendMode) {
|
when (state.sendMode) {
|
||||||
SendMode.REGULAR -> {
|
is SendMode.REGULAR -> {
|
||||||
val slashCommandResult = CommandParser.parseSplashCommand(action.text)
|
val slashCommandResult = CommandParser.parseSplashCommand(action.text)
|
||||||
|
|
||||||
when (slashCommandResult) {
|
when (slashCommandResult) {
|
||||||
@ -226,6 +272,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
// Send the text message to the room
|
// Send the text message to the room
|
||||||
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
|
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
|
||||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
||||||
|
popDraft()
|
||||||
}
|
}
|
||||||
is ParsedCommand.ErrorSyntax -> {
|
is ParsedCommand.ErrorSyntax -> {
|
||||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command))
|
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command))
|
||||||
@ -238,6 +285,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
}
|
}
|
||||||
is ParsedCommand.Invite -> {
|
is ParsedCommand.Invite -> {
|
||||||
handleInviteSlashCommand(slashCommandResult)
|
handleInviteSlashCommand(slashCommandResult)
|
||||||
|
popDraft()
|
||||||
}
|
}
|
||||||
is ParsedCommand.SetUserPowerLevel -> {
|
is ParsedCommand.SetUserPowerLevel -> {
|
||||||
// TODO
|
// TODO
|
||||||
@ -251,6 +299,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
vectorPreferences.setMarkdownEnabled(slashCommandResult.enable)
|
vectorPreferences.setMarkdownEnabled(slashCommandResult.enable)
|
||||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled(
|
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled(
|
||||||
if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled))
|
if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled))
|
||||||
|
popDraft()
|
||||||
}
|
}
|
||||||
is ParsedCommand.UnbanUser -> {
|
is ParsedCommand.UnbanUser -> {
|
||||||
// TODO
|
// TODO
|
||||||
@ -275,9 +324,11 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
is ParsedCommand.SendEmote -> {
|
is ParsedCommand.SendEmote -> {
|
||||||
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
|
room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
|
||||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled())
|
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.SlashCommandHandled())
|
||||||
|
popDraft()
|
||||||
}
|
}
|
||||||
is ParsedCommand.ChangeTopic -> {
|
is ParsedCommand.ChangeTopic -> {
|
||||||
handleChangeTopicSlashCommand(slashCommandResult)
|
handleChangeTopicSlashCommand(slashCommandResult)
|
||||||
|
popDraft()
|
||||||
}
|
}
|
||||||
is ParsedCommand.ChangeDisplayName -> {
|
is ParsedCommand.ChangeDisplayName -> {
|
||||||
// TODO
|
// TODO
|
||||||
@ -285,11 +336,11 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is SendMode.EDIT -> {
|
is SendMode.EDIT -> {
|
||||||
|
|
||||||
//is original event a reply?
|
//is original event a reply?
|
||||||
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId
|
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId
|
||||||
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
|
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
|
||||||
if (inReplyTo != null) {
|
if (inReplyTo != null) {
|
||||||
//TODO check if same content?
|
//TODO check if same content?
|
||||||
room.getTimeLineEvent(inReplyTo)?.let {
|
room.getTimeLineEvent(inReplyTo)?.let {
|
||||||
@ -298,27 +349,24 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
} else {
|
} else {
|
||||||
val messageContent: MessageContent? =
|
val messageContent: MessageContent? =
|
||||||
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
|
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
|
||||||
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
||||||
val existingBody = messageContent?.body ?: ""
|
val existingBody = messageContent?.body ?: ""
|
||||||
if (existingBody != action.text) {
|
if (existingBody != action.text) {
|
||||||
room.editTextMessage(state.sendMode.timelineEvent.root.eventId
|
room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "",
|
||||||
?: "", messageContent?.type
|
messageContent?.type ?: MessageType.MSGTYPE_TEXT,
|
||||||
?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
|
action.text,
|
||||||
|
action.autoMarkdown)
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Same message content, do not send edition")
|
Timber.w("Same message content, do not send edition")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
sendMode = SendMode.REGULAR
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
||||||
|
popDraft()
|
||||||
}
|
}
|
||||||
is SendMode.QUOTE -> {
|
is SendMode.QUOTE -> {
|
||||||
val messageContent: MessageContent? =
|
val messageContent: MessageContent? =
|
||||||
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
|
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
|
||||||
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
||||||
val textMsg = messageContent?.body
|
val textMsg = messageContent?.body
|
||||||
|
|
||||||
val finalText = legacyRiotQuoteText(textMsg, action.text)
|
val finalText = legacyRiotQuoteText(textMsg, action.text)
|
||||||
@ -333,29 +381,25 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
} else {
|
} else {
|
||||||
room.sendFormattedTextMessage(finalText, htmlText)
|
room.sendFormattedTextMessage(finalText, htmlText)
|
||||||
}
|
}
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
sendMode = SendMode.REGULAR
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
||||||
|
popDraft()
|
||||||
}
|
}
|
||||||
is SendMode.REPLY -> {
|
is SendMode.REPLY -> {
|
||||||
state.sendMode.timelineEvent.let {
|
state.sendMode.timelineEvent.let {
|
||||||
room.replyToMessage(it, action.text, action.autoMarkdown)
|
room.replyToMessage(it, action.text, action.autoMarkdown)
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
sendMode = SendMode.REGULAR
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
_sendMessageResultLiveData.postLiveEvent(SendMessageResult.MessageSent)
|
||||||
|
popDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun popDraft() {
|
||||||
|
filterDraftUpdate = false
|
||||||
|
room.deleteDraft()
|
||||||
|
}
|
||||||
|
|
||||||
private fun legacyRiotQuoteText(quotedText: String?, myText: String): String {
|
private fun legacyRiotQuoteText(quotedText: String?, myText: String): String {
|
||||||
val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray()
|
val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray()
|
||||||
var quotedTextMsg = StringBuilder()
|
var quotedTextMsg = StringBuilder()
|
||||||
@ -469,27 +513,56 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleEditAction(action: RoomDetailActions.EnterEditMode) {
|
private fun handleEditAction(action: RoomDetailActions.EnterEditMode) {
|
||||||
room.getTimeLineEvent(action.eventId)?.let {
|
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
|
||||||
enterEditMode(it)
|
timelineEvent.root.eventId?.let {
|
||||||
|
filterDraftUpdate = false
|
||||||
|
room.saveDraft(UserDraft.EDIT(it, timelineEvent.getTextEditableContent() ?: ""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) {
|
private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) {
|
||||||
room.getTimeLineEvent(action.eventId)?.let {
|
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
|
||||||
setState {
|
withState { state ->
|
||||||
copy(
|
// Save a new draft and keep the previously entered text, if it was not an edit
|
||||||
sendMode = SendMode.QUOTE(it)
|
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) {
|
private fun handleReplyAction(action: RoomDetailActions.EnterReplyMode) {
|
||||||
room.getTimeLineEvent(action.eventId)?.let {
|
room.getTimeLineEvent(action.eventId)?.let { timelineEvent ->
|
||||||
setState {
|
withState { state ->
|
||||||
copy(
|
// Save a new draft and keep the previously entered text, if it was not an edit
|
||||||
sendMode = SendMode.REPLY(it)
|
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...)
|
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
|
||||||
*/
|
*/
|
||||||
sealed class SendMode {
|
sealed class SendMode(open val text: String) {
|
||||||
object REGULAR : SendMode()
|
data class REGULAR(override val text: String) : SendMode(text)
|
||||||
data class QUOTE(val timelineEvent: TimelineEvent) : SendMode()
|
data class QUOTE(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
|
||||||
data class EDIT(val timelineEvent: TimelineEvent) : SendMode()
|
data class EDIT(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
|
||||||
data class REPLY(val timelineEvent: TimelineEvent) : SendMode()
|
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RoomDetailViewState(
|
data class RoomDetailViewState(
|
||||||
@ -47,7 +47,7 @@ data class RoomDetailViewState(
|
|||||||
val timeline: Timeline? = null,
|
val timeline: Timeline? = null,
|
||||||
val asyncInviter: Async<User> = Uninitialized,
|
val asyncInviter: Async<User> = Uninitialized,
|
||||||
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
|
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
|
||||||
val sendMode: SendMode = SendMode.REGULAR,
|
val sendMode: SendMode = SendMode.REGULAR(""),
|
||||||
val isEncrypted: Boolean = false,
|
val isEncrypted: Boolean = false,
|
||||||
val tombstoneEvent: Event? = null,
|
val tombstoneEvent: Event? = null,
|
||||||
val tombstoneEventHandling: Async<String> = Uninitialized,
|
val tombstoneEventHandling: Async<String> = Uninitialized,
|
||||||
|
@ -40,6 +40,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
|
|||||||
@EpoxyAttribute var avatarUrl: String? = null
|
@EpoxyAttribute var avatarUrl: String? = null
|
||||||
@EpoxyAttribute var unreadNotificationCount: Int = 0
|
@EpoxyAttribute var unreadNotificationCount: Int = 0
|
||||||
@EpoxyAttribute var hasUnreadMessage: Boolean = false
|
@EpoxyAttribute var hasUnreadMessage: Boolean = false
|
||||||
|
@EpoxyAttribute var hasDraft: Boolean = false
|
||||||
@EpoxyAttribute var showHighlighted: Boolean = false
|
@EpoxyAttribute var showHighlighted: Boolean = false
|
||||||
@EpoxyAttribute var listener: (() -> Unit)? = null
|
@EpoxyAttribute var listener: (() -> Unit)? = null
|
||||||
|
|
||||||
@ -52,6 +53,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
|
|||||||
holder.lastEventView.text = lastFormattedEvent
|
holder.lastEventView.text = lastFormattedEvent
|
||||||
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
|
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
|
||||||
holder.unreadIndentIndicator.isVisible = hasUnreadMessage
|
holder.unreadIndentIndicator.isVisible = hasUnreadMessage
|
||||||
|
holder.draftView.isVisible = hasDraft
|
||||||
avatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
|
avatarRenderer.render(avatarUrl, roomId, roomName.toString(), holder.avatarImageView)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,6 +62,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
|
|||||||
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomUnreadCounterBadgeView)
|
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomUnreadCounterBadgeView)
|
||||||
val unreadIndentIndicator by bind<View>(R.id.roomUnreadIndicator)
|
val unreadIndentIndicator by bind<View>(R.id.roomUnreadIndicator)
|
||||||
val lastEventView by bind<TextView>(R.id.roomLastEventView)
|
val lastEventView by bind<TextView>(R.id.roomLastEventView)
|
||||||
|
val draftView by bind<ImageView>(R.id.roomDraftBadge)
|
||||||
val lastEventTimeView by bind<TextView>(R.id.roomLastEventTimeView)
|
val lastEventTimeView by bind<TextView>(R.id.roomLastEventTimeView)
|
||||||
val avatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
|
val avatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
|
||||||
val rootView by bind<ViewGroup>(R.id.itemRoomLayout)
|
val rootView by bind<ViewGroup>(R.id.itemRoomLayout)
|
||||||
|
@ -133,6 +133,7 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
|
|||||||
.showHighlighted(showHighlighted)
|
.showHighlighted(showHighlighted)
|
||||||
.unreadNotificationCount(unreadCount)
|
.unreadNotificationCount(unreadCount)
|
||||||
.hasUnreadMessage(roomSummary.hasUnreadMessages)
|
.hasUnreadMessage(roomSummary.hasUnreadMessages)
|
||||||
|
.hasDraft(roomSummary.userDrafts.isNotEmpty())
|
||||||
.listener { listener?.onRoomSelected(roomSummary) }
|
.listener { listener?.onRoomSelected(roomSummary) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,13 +56,27 @@
|
|||||||
android:textSize="15sp"
|
android:textSize="15sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
app:layout_constrainedWidth="true"
|
app:layout_constrainedWidth="true"
|
||||||
app:layout_constraintEnd_toStartOf="@+id/roomUnreadCounterBadgeView"
|
app:layout_constraintEnd_toStartOf="@+id/roomDraftBadge"
|
||||||
app:layout_constraintHorizontal_bias="0.0"
|
app:layout_constraintHorizontal_bias="0.0"
|
||||||
app:layout_constraintHorizontal_chainStyle="packed"
|
app:layout_constraintHorizontal_chainStyle="packed"
|
||||||
app:layout_constraintStart_toEndOf="@id/roomAvatarImageView"
|
app:layout_constraintStart_toEndOf="@id/roomAvatarImageView"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="@sample/matrix.json/data/displayName" />
|
tools:text="@sample/matrix.json/data/displayName" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/roomDraftBadge"
|
||||||
|
android:layout_width="16dp"
|
||||||
|
android:layout_height="16dp"
|
||||||
|
android:layout_marginLeft="4dp"
|
||||||
|
android:layout_marginRight="4dp"
|
||||||
|
android:src="@drawable/ic_edit"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/roomNameView"
|
||||||
|
app:layout_constraintEnd_toStartOf="@+id/roomUnreadCounterBadgeView"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/roomNameView"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/roomNameView"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
|
<im.vector.riotx.features.home.room.list.UnreadCounterBadgeView
|
||||||
android:id="@+id/roomUnreadCounterBadgeView"
|
android:id="@+id/roomUnreadCounterBadgeView"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@ -76,12 +90,14 @@
|
|||||||
android:paddingRight="4dp"
|
android:paddingRight="4dp"
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:textSize="10sp"
|
android:textSize="10sp"
|
||||||
|
android:visibility="gone"
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/roomNameView"
|
app:layout_constraintBottom_toBottomOf="@+id/roomNameView"
|
||||||
app:layout_constraintEnd_toStartOf="@+id/roomLastEventTimeView"
|
app:layout_constraintEnd_toStartOf="@+id/roomLastEventTimeView"
|
||||||
app:layout_constraintStart_toEndOf="@+id/roomNameView"
|
app:layout_constraintStart_toEndOf="@+id/roomDraftBadge"
|
||||||
app:layout_constraintTop_toTopOf="@+id/roomNameView"
|
app:layout_constraintTop_toTopOf="@+id/roomNameView"
|
||||||
tools:background="@drawable/bg_unread_highlight"
|
tools:background="@drawable/bg_unread_highlight"
|
||||||
tools:text="4" />
|
tools:text="4"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/roomLastEventTimeView"
|
android:id="@+id/roomLastEventTimeView"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user