Undo Reaction

This commit is contained in:
Valere 2019-05-17 17:15:44 +02:00
parent 207579c59f
commit 6eafa3c43d
29 changed files with 587 additions and 75 deletions

View File

@ -18,10 +18,12 @@ package im.vector.matrix.android.session.room.timeline

import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.InstrumentedTest
import im.vector.matrix.android.api.auth.data.Credentials
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.internal.session.room.EventRelationExtractor
import im.vector.matrix.android.internal.session.room.membership.SenderRoomMemberExtractor
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor
import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline
import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory
import im.vector.matrix.android.internal.session.room.timeline.TokenChunkEventPersistor
@ -55,7 +57,8 @@ internal class TimelineTest : InstrumentedTest {

private fun createTimeline(initialEventId: String? = null): Timeline {
val taskExecutor = TaskExecutor(testCoroutineDispatchers)
val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy)
val erau = EventRelationsAggregationUpdater(Credentials("", "", "", null, null))
val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy, erau)
val paginationTask = FakePaginationTask(tokenChunkEventPersistor)
val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor)
val roomMemberExtractor = SenderRoomMemberExtractor(ROOM_ID)

View File

@ -25,5 +25,5 @@ data class UnsignedData(
@Json(name = "redacted_because") val redactedEvent: Event? = null,
@Json(name = "transaction_id") val transactionId: String? = null,
@Json(name = "prev_content") val prevContent: Map<String, Any>? = null,
@Json(name = "m.relations") val relations: AggregatedRelations?
@Json(name = "m.relations") val relations: AggregatedRelations? = null
)

View File

@ -19,6 +19,7 @@ package im.vector.matrix.android.api.session.room
import androidx.lifecycle.LiveData
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.annotation.ReactionService
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService
@ -27,7 +28,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineService
/**
* This interface defines methods to interact within a room.
*/
interface Room : TimelineService, SendService, ReadService, MembershipService, StateService {
interface Room : TimelineService, SendService, ReadService, MembershipService, StateService , ReactionService{

/**
* The roomId of this room

View File

@ -0,0 +1,38 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.api.session.room.model.annotation

import im.vector.matrix.android.api.util.Cancelable

interface ReactionService {


/**
* Sends a reaction (emoji) to the targetedEvent.
* @param reaction the reaction (preferably emoji)
* @param targetEventId the id of the event being reacted
*/
fun sendReaction(reaction: String, targetEventId: String): Cancelable


/**
* Undo a reaction (emoji) to the targetedEvent.
* @param reaction the reaction (preferably emoji)
* @param targetEventId the id of the event being reacted
*/
fun undoReaction(reaction: String, targetEventId: String, myUserId: String)//: Cancelable

}

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.room.send

import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.util.Cancelable

@ -48,7 +49,6 @@ interface SendService {
*/
fun sendMedias(attachments: List<ContentAttachmentData>): Cancelable


fun sendReaction(reaction: String, targetEventId: String) : Cancelable
fun redactEvent(event: Event, reason: String?): Cancelable

}

View File

@ -22,6 +22,7 @@ import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.room.Room
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.annotation.ReactionService
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService
@ -39,12 +40,14 @@ internal class DefaultRoom(
private val sendService: SendService,
private val stateService: StateService,
private val readService: ReadService,
private val reactionService: ReactionService,
private val roomMembersService: MembershipService
) : Room,
TimelineService by timelineService,
SendService by sendService,
StateService by stateService,
ReadService by readService,
TimelineService by timelineService,
SendService by sendService,
StateService by stateService,
ReadService by readService,
ReactionService by reactionService,
MembershipService by roomMembersService {

override val roomSummary: LiveData<RoomSummary> by lazy {

View File

@ -67,13 +67,14 @@ internal class EventRelationsAggregationUpdater(private val credentials: Credent
sum.key = reaction
sum.firstTimestamp = event.originServerTs ?: 0
sum.count = 1
sum.sourceEvents.add(event.eventId)
sum.addedByMe = sum.addedByMe || (credentials.userId == event.sender)
eventSummary.reactionsSummary.add(sum)
} else {
//is this a known event (is possible? pagination?)
if (!sum.sourceEvents.contains(eventId)) {
sum.count += 1
sum.sourceEvents.add(eventId)
sum.sourceEvents.add(event.eventId)
sum.addedByMe = sum.addedByMe || (credentials.userId == event.sender)
}
}

View File

@ -197,4 +197,22 @@ internal interface RoomAPI {
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/leave")
fun leave(@Path("roomId") roomId: String,
@Body params: Map<String, String>): Call<Unit>

/**
* Strips all information out of an event which isn't critical to the integrity of the server-side representation of the room.
* This cannot be undone.
* Users may redact their own events, and any user with a power level greater than or equal to the redact power level of the room may redact events there.
*
* @param txId the transaction Id
* @param roomId the room id
* @param eventId the event to delete
* @param reason json containing reason key {"reason": "Indecent material"}
*/
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/redact/{eventId}/{txnId}")
fun redactEvent(
@Path("txnId") txId: String,
@Path("roomId") roomId: String,
@Path("eventId") parent_id: String,
@Body reason: Map<String, String>
): Call<SendResponse>
}

View File

@ -19,6 +19,8 @@ package im.vector.matrix.android.internal.session.room
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
import im.vector.matrix.android.internal.session.room.annotation.FindReactionEventForUndoTask
import im.vector.matrix.android.internal.session.room.annotation.DefaultReactionService
import im.vector.matrix.android.internal.session.room.membership.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.membership.SenderRoomMemberExtractor
import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask
@ -45,6 +47,7 @@ internal class RoomFactory(private val monarchy: Monarchy,
private val paginationTask: PaginationTask,
private val contextOfEventTask: GetContextOfEventTask,
private val setReadMarkersTask: SetReadMarkersTask,
private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
private val joinRoomTask: JoinRoomTask,
private val leaveRoomTask: LeaveRoomTask) {

@ -53,6 +56,7 @@ internal class RoomFactory(private val monarchy: Monarchy,
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor, EventRelationExtractor())
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, timelineEventFactory, contextOfEventTask, paginationTask)
val sendService = DefaultSendService(roomId, eventFactory, monarchy)
val reactionService = DefaultReactionService(roomId, eventFactory, monarchy, findReactionEventForUndoTask, taskExecutor)
val stateService = DefaultStateService(roomId, taskExecutor, sendStateTask)
val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask)
val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask)
@ -64,6 +68,7 @@ internal class RoomFactory(private val monarchy: Monarchy,
sendService,
stateService,
readService,
reactionService,
roomMembersService
)
}

View File

@ -17,6 +17,8 @@
package im.vector.matrix.android.internal.session.room

import im.vector.matrix.android.internal.session.DefaultSession
import im.vector.matrix.android.internal.session.room.annotation.DefaultFindReactionEventForUndoTask
import im.vector.matrix.android.internal.session.room.annotation.FindReactionEventForUndoTask
import im.vector.matrix.android.internal.session.room.create.CreateRoomTask
import im.vector.matrix.android.internal.session.room.create.DefaultCreateRoomTask
import im.vector.matrix.android.internal.session.room.membership.DefaultLoadRoomMembersTask
@ -98,5 +100,10 @@ class RoomModule {
DefaultSendStateTask(get()) as SendStateTask
}

scope(DefaultSession.SCOPE) {
DefaultFindReactionEventForUndoTask(get()) as FindReactionEventForUndoTask
}


}
}

View File

@ -0,0 +1,133 @@
/*
* 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.annotation

import androidx.work.*
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.annotation.ReactionService
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.helper.addSendingEvent
import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.room.send.RedactEventWorker
import im.vector.matrix.android.internal.session.room.send.SendEventWorker
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionAsync
import java.util.concurrent.TimeUnit

private const val REACTION_WORK = "REACTION_WORK"
private const val BACKOFF_DELAY = 10_000L

private val WORK_CONSTRAINTS = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()

internal class DefaultReactionService(private val roomId: String,
private val eventFactory: LocalEchoEventFactory,
private val monarchy: Monarchy,
private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
private val taskExecutor: TaskExecutor)
: ReactionService {


override fun sendReaction(reaction: String, targetEventId: String): Cancelable {
val event = eventFactory.createReactionEvent(roomId, targetEventId, reaction).also {
saveLocalEcho(it)
}
val sendRelationWork = createSendRelationWork(event)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, sendRelationWork)
.enqueue()
return CancelableWork(sendRelationWork.id)
}


private fun createSendRelationWork(event: Event): OneTimeWorkRequest {
//TODO use the new API to send relation (for now use regular send)
val sendContentWorkerParams = SendEventWorker.Params(
roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}

override fun undoReaction(reaction: String, targetEventId: String, myUserId: String)/*: Cancelable*/ {

val params = FindReactionEventForUndoTask.Params(
roomId,
targetEventId,
reaction,
myUserId
)
findReactionEventForUndoTask.configureWith(params)
.enableRetry()
.dispatchTo(object : MatrixCallback<FindReactionEventForUndoTask.Result> {
override fun onSuccess(data: FindReactionEventForUndoTask.Result) {
data.redactEventId?.let { toRedact ->
val redactWork = createRedactEventWork(toRedact, null)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, redactWork)
.enqueue()
}
}
})
.executeBy(taskExecutor)

}

private fun buildWorkIdentifier(identifier: String): String {
return "${roomId}_$identifier"
}

private fun saveLocalEcho(event: Event) {
monarchy.tryTransactionAsync { realm ->
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()
?: return@tryTransactionAsync
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId)
?: return@tryTransactionAsync

roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0)
}
}

//TODO duplicate with send service?
private fun createRedactEventWork(eventId: String, reason: String?): OneTimeWorkRequest {

//TODO create local echo of m.room.redaction event?

val sendContentWorkerParams = RedactEventWorker.Params(
roomId, eventId, reason)
val redactWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<RedactEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(redactWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}
}

View File

@ -0,0 +1,75 @@
/*
* 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.annotation

import arrow.core.Try
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.ReactionAggregatedSummaryEntityFields
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.Task
import io.realm.Realm


internal interface FindReactionEventForUndoTask : Task<FindReactionEventForUndoTask.Params, FindReactionEventForUndoTask.Result> {

data class Params(
val roomId: String,
val eventId: String,
val reaction: String,
val myUserId: String
)

data class Result(
val redactEventId: String?
)

}

internal class DefaultFindReactionEventForUndoTask(private val monarchy: Monarchy) : FindReactionEventForUndoTask {

override fun execute(params: FindReactionEventForUndoTask.Params): Try<FindReactionEventForUndoTask.Result> {
return Try {
var eventId: String? = null
monarchy.doWithRealm { realm ->
eventId = getReactionToRedact(realm, params.reaction, params.eventId, params.myUserId)?.eventId
}
FindReactionEventForUndoTask.Result(eventId)
}
}

private fun getReactionToRedact(realm: Realm, reaction: String, eventId: String, userId: String): EventEntity? {
val summary = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
if (summary != null) {
summary.reactionsSummary.where()
.equalTo(ReactionAggregatedSummaryEntityFields.KEY, reaction)
.findFirst()?.let {
//want to find the event orignated by me!
it.sourceEvents.forEach {
//find source event
EventEntity.where(realm, it).findFirst()?.let { eventEntity ->
//is it mine?
if (eventEntity.sender == userId) {
return eventEntity
}
}
}
}
}
return null
}
}

View File

@ -1,4 +1,19 @@
package im.vector.matrix.android.internal.session.room.send
/*
* 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.annotation

import android.content.Context
import androidx.work.Worker
@ -11,6 +26,7 @@ import im.vector.matrix.android.api.session.room.model.annotation.ReactionInfo
import im.vector.matrix.android.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.session.room.send.SendResponse
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import org.koin.standalone.inject

@ -28,7 +44,7 @@ class SendRelationWorker(context: Context, params: WorkerParameters)
private val roomAPI by inject<RoomAPI>()

override fun doWork(): Result {
val params = WorkerParamsFactory.fromData<SendRelationWorker.Params>(inputData)
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure()

val localEvent = params.event

View File

@ -36,7 +36,7 @@ internal class EventsPruner(monarchy: Monarchy) :

override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
val redactionEvents = inserted
.mapNotNull { it.asDomain().redacts }
.mapNotNull { it.asDomain() }

val pruneEventWorkerParams = PruneEventWorker.Params(redactionEvents)
val workData = WorkerParamsFactory.toData(pruneEventWorkerParams)

View File

@ -21,15 +21,24 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import com.zhuinden.monarchy.Monarchy
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.UnsignedData
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.annotation.ReactionContent
import im.vector.matrix.android.internal.database.mapper.ContentMapper
import im.vector.matrix.android.internal.database.mapper.EventMapper
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.ReactionAggregatedSummaryEntityFields
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.Realm
import org.koin.standalone.inject
import timber.log.Timber

internal class PruneEventWorker(context: Context,
workerParameters: WorkerParameters
@ -37,57 +46,105 @@ internal class PruneEventWorker(context: Context,

@JsonClass(generateAdapter = true)
internal class Params(
val eventIdsToRedact: List<String>
val redactionEvents: List<Event>
)

private val monarchy by inject<Monarchy>()

override fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure()
?: return Result.failure()

val result = monarchy.tryTransactionSync { realm ->
params.eventIdsToRedact.forEach { eventId ->
pruneEvent(realm, eventId)
params.redactionEvents.forEach { event ->
pruneEvent(realm, event)
}
}
return result.fold({ Result.retry() }, { Result.success() })
return result.fold({
Result.retry()
}, {
Result.success()
})
}

private fun pruneEvent(realm: Realm, eventIdToRedact: String) {
if (eventIdToRedact.isEmpty()) {
private fun pruneEvent(realm: Realm, redactionEvent: Event) {
if (redactionEvent.redacts.isNullOrBlank()) {
return
}

val eventToPrune = EventEntity.where(realm, eventId = eventIdToRedact).findFirst()
?: return
val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst()
?: return

val allowedKeys = computeAllowedKeys(eventToPrune.type)
if (allowedKeys.isNotEmpty()) {
val prunedContent = ContentMapper.map(eventToPrune.content)?.filterKeys { key -> allowedKeys.contains(key) }
eventToPrune.content = ContentMapper.map(prunedContent)
} else {
when (eventToPrune.type) {
EventType.MESSAGE -> {
Timber.d("REDACTION for message ${eventToPrune.eventId}")
val unsignedData = EventMapper.map(eventToPrune).unsignedData
?: UnsignedData(null, null)
val modified = unsignedData.copy(redactedEvent = redactionEvent)
eventToPrune.content = ContentMapper.map(emptyMap())
eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified)
}
EventType.REACTION -> {
Timber.d("REDACTION of reaction ${eventToPrune.eventId}")
//delete a reaction, need to update the annotation summary if any
val reactionContent: ReactionContent = EventMapper.map(eventToPrune).content.toModel()
?: return
val eventThatWasReacted = reactionContent.relatesTo?.eventId ?: return

val reactionkey = reactionContent.relatesTo.key
Timber.d("REMOVE reaction for key $reactionkey")
val summary = EventAnnotationsSummaryEntity.where(realm, eventThatWasReacted).findFirst()
if (summary != null) {
summary.reactionsSummary.where()
.equalTo(ReactionAggregatedSummaryEntityFields.KEY, reactionkey)
.findFirst()?.let { summary ->
Timber.d("Find summary for key with ${summary.sourceEvents.size} known reactions (count:${summary.count})")
Timber.d("Known reactions ${summary.sourceEvents.joinToString(",")}")
if (summary.sourceEvents.contains(eventToPrune.eventId)) {
Timber.d("REMOVE reaction for key $reactionkey")
summary.sourceEvents.remove(eventToPrune.eventId)
Timber.d("Known reactions after ${summary.sourceEvents.joinToString(",")}")
summary.count = summary.count - 1
if (summary.count == 0) {
//delete!
summary.deleteFromRealm()
}
} else {
Timber.e("## Cannot remove summary from count, corresponding reaction ${eventToPrune.eventId} is not known")
}
}
} else {
Timber.e("## Cannot find summary for key $reactionkey")
}
}
}
}
}

private fun computeAllowedKeys(type: String): List<String> {
// Add filtered content, allowed keys in content depends on the event type
return when (type) {
EventType.STATE_ROOM_MEMBER -> listOf("membership")
EventType.STATE_ROOM_CREATE -> listOf("creator")
EventType.STATE_ROOM_JOIN_RULES -> listOf("join_rule")
EventType.STATE_ROOM_MEMBER -> listOf("membership")
EventType.STATE_ROOM_CREATE -> listOf("creator")
EventType.STATE_ROOM_JOIN_RULES -> listOf("join_rule")
EventType.STATE_ROOM_POWER_LEVELS -> listOf("users",
"users_default",
"events",
"events_default",
"state_default",
"ban",
"kick",
"redact",
"invite")
EventType.STATE_ROOM_ALIASES -> listOf("aliases")
EventType.STATE_CANONICAL_ALIAS -> listOf("alias")
EventType.FEEDBACK -> listOf("type", "target_event_id")
else -> emptyList()
"users_default",
"events",
"events_default",
"state_default",
"ban",
"kick",
"redact",
"invite")
EventType.STATE_ROOM_ALIASES -> listOf("aliases")
EventType.STATE_CANONICAL_ALIAS -> listOf("alias")
EventType.FEEDBACK -> listOf("type", "target_event_id")
else -> emptyList()
}
}


View File

@ -16,13 +16,7 @@

package im.vector.matrix.android.internal.session.room.send

import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequest
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.*
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
@ -74,16 +68,14 @@ internal class DefaultSendService(private val roomId: String,
return cancelableBag
}


override fun sendReaction(reaction: String, targetEventId: String) : Cancelable {
val event = eventFactory.createReactionEvent(roomId,targetEventId,reaction).also {
saveLocalEcho(it)
}
val sendRelationWork = createSendRelationWork(event)
override fun redactEvent(event: Event, reason: String?): Cancelable {
//TODO manage local echo ?
//TODO manage media/attachements?
val redactWork = createRedactEventWork(event, reason)
WorkManager.getInstance()
.beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, sendRelationWork)
.beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, redactWork)
.enqueue()
return CancelableWork(sendRelationWork.id)
return CancelableWork(redactWork.id)
}

override fun sendMedia(attachment: ContentAttachmentData): Cancelable {
@ -105,9 +97,9 @@ internal class DefaultSendService(private val roomId: String,
private fun saveLocalEcho(event: Event) {
monarchy.tryTransactionAsync { realm ->
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()
?: return@tryTransactionAsync
?: return@tryTransactionAsync
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId)
?: return@tryTransactionAsync
?: return@tryTransactionAsync

roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0)
}
@ -128,15 +120,17 @@ internal class DefaultSendService(private val roomId: String,
.build()
}

private fun createSendRelationWork(event: Event): OneTimeWorkRequest {
//TODO use the new API to send relation (for now use regular send)
val sendContentWorkerParams = SendEventWorker.Params(
roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)
private fun createRedactEventWork(event: Event, reason: String?): OneTimeWorkRequest {

return OneTimeWorkRequestBuilder<SendEventWorker>()
//TODO create local echo of m.room.redaction event?

val sendContentWorkerParams = RedactEventWorker.Params(
roomId, event.eventId!!, reason)
val redactWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<RedactEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setInputData(redactWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}

View File

@ -0,0 +1,69 @@
/*
* 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.send

import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import org.koin.standalone.inject
import java.util.*

internal class RedactEventWorker(context: Context, params: WorkerParameters)
: Worker(context, params), MatrixKoinComponent {

@JsonClass(generateAdapter = true)
internal data class Params(
val roomId: String,
val eventId: String,
val reason: String?
)

private val roomAPI by inject<RoomAPI>()

override fun doWork(): Result {
val params = WorkerParamsFactory.fromData<RedactEventWorker.Params>(inputData)
?: return Result.failure()

if (params.eventId == null) {
return Result.failure()
}
val txID = UUID.randomUUID().toString()

val result = executeRequest<SendResponse> {
apiCall = roomAPI.redactEvent(
txID,
params.roomId,
params.eventId,
if (params.reason == null) emptyMap() else mapOf("reason" to params.reason)
)
}
return result.fold({
when (it) {
is Failure.NetworkConnection -> Result.retry()
else -> Result.failure()
}
}, {
Result.success()
})
}

}

View File

@ -92,7 +92,7 @@ internal class DefaultTimeline(

private lateinit var eventRelations: RealmResults<EventAnnotationsSummaryEntity>

private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet ->
private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { results, changeSet ->
if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) {
handleInitialLoad()
} else {
@ -122,8 +122,22 @@ internal class DefaultTimeline(
buildTimelineEvents(startDisplayIndex, direction, range.length.toLong())
postSnapshot()
}

}

var hasChanged = false
changeSet.changes.forEach {index ->
val eventEntity = results[index]
eventEntity?.eventId?.let { eventId ->
builtEventsIdMap[eventId]?.let { builtIndex ->
//Update the relation of existing event
builtEvents[builtIndex]?.let { te ->
builtEvents[builtIndex] = timelineEventFactory.create(eventEntity)
hasChanged = true
}
}
}
}
if (hasChanged) postSnapshot()
}
}


View File

@ -28,7 +28,10 @@ sealed class RoomDetailActions {
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()
data class SendReaction(val reaction: String, val targetEventId: String) : RoomDetailActions()
data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailActions()
data class UndoReaction(val targetEventId: String, val key: String, val reason: String? = "") : RoomDetailActions()
object AcceptInvite : RoomDetailActions()
object RejectInvite : RoomDetailActions()


}

View File

@ -521,7 +521,8 @@ class RoomDetailFragment :
//we should test the current real state of reaction on this event
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, informationData.eventId))
} else {
//TODO it's an undo :/
//I need to redact a reaction
roomDetailViewModel.process(RoomDetailActions.UndoReaction(informationData.eventId,reaction))
}
}

@ -546,7 +547,11 @@ class RoomDetailFragment :
snack.view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.notification_accent_color))
snack.show()
}
MessageMenuViewModel.ACTION_SHARE -> {
MessageMenuViewModel.ACTION_DELETE -> {
val eventId = actionData.data?.toString() ?: return
roomDetailViewModel.process(RoomDetailActions.RedactAction(eventId,context?.getString(R.string.event_redacted_by_user_reason)))
}
MessageMenuViewModel.ACTION_SHARE -> {
//TODO current data communication is too limited
//Need to now the media type
actionData.data?.toString()?.let {

View File

@ -80,6 +80,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
is RoomDetailActions.SendReaction -> handleSendReaction(action)
is RoomDetailActions.AcceptInvite -> handleAcceptInvite()
is RoomDetailActions.RejectInvite -> handleRejectInvite()
is RoomDetailActions.RedactAction -> handleRedactEvent(action)
}
}

@ -190,6 +191,16 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
room.sendReaction(action.reaction, action.targetEventId)
}

private fun handleRedactEvent(action: RoomDetailActions.RedactAction) {
val event = room.getTimeLineEvent(action.targetEventId) ?: return
room.redactEvent(event.root, action.reason)
}

private fun handleUndoReact(action: RoomDetailActions.UndoReaction) {
room.undoReaction(action.key, action.targetEventId, session.sessionParams.credentials.userId)
}


private fun handleSendMedia(action: RoomDetailActions.SendMedia) {
val attachments = action.mediaFiles.map {
ContentAttachmentData(

View File

@ -73,6 +73,10 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent.body))
}

if(canRedact(event, currentSession.sessionParams.credentials.userId)) {
this.add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_material_delete, event.root.eventId))
}

if (canQuote(event, messageContent)) {
//TODO quote icon
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, parcel.eventId))
@ -148,6 +152,14 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
}
}

private fun canRedact(event: TimelineEvent, myUserId: String): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.type != EventType.MESSAGE) return false
//TODO if user is admin or moderator
return event.root.sender == myUserId
}


private fun canCopy(type: String): Boolean {
return when (type) {
MessageType.MSGTYPE_TEXT,
@ -162,6 +174,8 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
}




private fun canShare(type: String): Boolean {
return when (type) {
MessageType.MSGTYPE_IMAGE,

View File

@ -66,7 +66,6 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
|| nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo

val messageContent: MessageContent = event.root.content.toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date)
val avatarUrl = event.senderAvatar
val memberName = event.senderName ?: event.root.sender ?: ""
@ -84,8 +83,12 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
orderedReactionList = event.annotations?.reactionsSummary?.map { Triple(it.key, it.count, it.addedByMe) }
)

//Test for reactions UX
//informationData.orderedReactionList = listOf( Triple("👍",1,false), Triple("👎",2,false))
if (event.root.unsignedData?.redactedEvent != null) {
//message is redacted
return buildRedactedItem(informationData)
}

val messageContent: MessageContent = event.root.content.toModel() ?: return null

// val all = event.root.toContent()
// val ev = all.toModel<Event>()
@ -347,6 +350,11 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}

private fun buildRedactedItem(informationData: MessageInformationData): RedactedMessageItem? {
return RedactedMessageItem_()
.informationData(informationData)
}

private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence {
val spannable = SpannableStringBuilder(body)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {

View File

@ -55,11 +55,11 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {

var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener {
override fun onReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString,true)
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, true)
}

override fun onUnReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString,false)
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, false)
}
}

@ -123,10 +123,6 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
}
}

override fun unbind(holder: H) {
super.unbind(holder)
}

protected fun View.renderSendState() {
isClickable = informationData.sendState.isSent()
alpha = if (informationData.sendState.isSent()) 1f else 0.5f

View File

@ -0,0 +1,22 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item

import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R

@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class RedactedMessageItem : AbsMessageItem<RedactedMessageItem.Holder>() {

@EpoxyAttribute
override lateinit var informationData: MessageInformationData

override fun getStubType(): Int = STUB_ID

class Holder : AbsMessageItem.Holder() {
override fun getStubId(): Int = STUB_ID
}

companion object {
private val STUB_ID = R.id.messageContentRedactedStub
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="10dp" />
<solid android:color="?vctr_list_divider_color" />
</shape>

View File

@ -81,6 +81,12 @@
android:layout="@layout/item_timeline_event_file_stub"
tools:ignore="MissingConstraints" />

<ViewStub
android:id="@+id/messageContentRedactedStub"
style="@style/TimelineContentStubLayoutParams"
android:layout_height="20dp"
android:layout="@layout/item_timeline_event_redacted_stub"
tools:ignore="MissingConstraints" />


<!-- TODO: For now we show 8 reactions maximum, this will need rework when needed-->

View File

@ -0,0 +1,5 @@
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="20dp"
android:background="@drawable/redacted_background"
/>

View File

@ -13,4 +13,7 @@
<string name="reactions_like">Like</string>
<string name="message_add_reaction">Add Reaction</string>

<string name="event_redacted_by_user_reason">Event deleted by user</string>
<string name="event_redacted_by_admin_reason">Event moderated by room admin</string>

</resources>