Skip to content

Commit 36d2246

Browse files
authored
Merge pull request #991 from vector-im/feature/bma/redactRegardingPowerLevel
Allow user with enough power level to redact other's messages (#969)
2 parents a4e2b7a + 5b95bd0 commit 36d2246

File tree

13 files changed

+113
-18
lines changed

13 files changed

+113
-18
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
7171
import io.element.android.libraries.matrix.api.room.MessageEventType
7272
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
7373
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
74+
import io.element.android.libraries.matrix.ui.room.canRedactAsState
7475
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
7576
import io.element.android.libraries.textcomposer.MessageComposerMode
7677
import kotlinx.coroutines.CoroutineScope
@@ -109,6 +110,7 @@ class MessagesPresenter @AssistedInject constructor(
109110

110111
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
111112
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
113+
val userHasPermissionToRedact by room.canRedactAsState(updateKey = syncUpdateFlow.value)
112114
var roomName: Async<String> by remember { mutableStateOf(Async.Uninitialized) }
113115
var roomAvatar: Async<AvatarData> by remember { mutableStateOf(Async.Uninitialized) }
114116
LaunchedEffect(syncUpdateFlow.value) {
@@ -165,6 +167,7 @@ class MessagesPresenter @AssistedInject constructor(
165167
roomName = roomName,
166168
roomAvatar = roomAvatar,
167169
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
170+
userHasPermissionToRedact = userHasPermissionToRedact,
168171
composerState = composerState,
169172
timelineState = timelineState,
170173
actionListState = actionListState,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ data class MessagesState(
3333
val roomName: Async<String>,
3434
val roomAvatar: Async<AvatarData>,
3535
val userHasPermissionToSendMessage: Boolean,
36+
val userHasPermissionToRedact: Boolean,
3637
val composerState: MessageComposerState,
3738
val timelineState: TimelineState,
3839
val actionListState: ActionListState,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ fun aMessagesState() = MessagesState(
5050
roomName = Async.Success("Room name"),
5151
roomAvatar = Async.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
5252
userHasPermissionToSendMessage = true,
53+
userHasPermissionToRedact = false,
5354
composerState = aMessageComposerState().copy(
5455
text = "Hello",
5556
isFullScreen = false,

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ fun MessagesView(
115115
fun onMessageLongClicked(event: TimelineItem.Event) {
116116
Timber.v("OnMessageLongClicked= ${event.id}")
117117
localView.hideKeyboard()
118-
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event))
118+
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event, state.userHasPermissionToRedact))
119119
}
120120

121121
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
2020

2121
sealed interface ActionListEvents {
2222
object Clear : ActionListEvents
23-
data class ComputeForMessage(val event: TimelineItem.Event) : ActionListEvents
23+
data class ComputeForMessage(val event: TimelineItem.Event, val canRedact: Boolean) : ActionListEvents
2424
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ class ActionListPresenter @Inject constructor(
5454
fun handleEvents(event: ActionListEvents) {
5555
when (event) {
5656
ActionListEvents.Clear -> target.value = ActionListState.Target.None
57-
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.event, target)
57+
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
58+
timelineItem = event.event,
59+
userCanRedact = event.canRedact,
60+
target = target,
61+
)
5862
}
5963
}
6064

@@ -65,7 +69,11 @@ class ActionListPresenter @Inject constructor(
6569
)
6670
}
6771

68-
private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState<ActionListState.Target>) = launch {
72+
private fun CoroutineScope.computeForMessage(
73+
timelineItem: TimelineItem.Event,
74+
userCanRedact: Boolean,
75+
target: MutableState<ActionListState.Target>
76+
) = launch {
6977
target.value = ActionListState.Target.Loading(timelineItem)
7078
val actions =
7179
when (timelineItem.content) {
@@ -102,7 +110,7 @@ class ActionListPresenter @Inject constructor(
102110
if (!timelineItem.isMine) {
103111
add(TimelineItemAction.ReportContent)
104112
}
105-
if (timelineItem.isMine) {
113+
if (timelineItem.isMine || userCanRedact) {
106114
add(TimelineItemAction.Redact)
107115
}
108116
}

features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ import io.element.android.features.messages.media.FakeLocalMediaFactory
4444
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
4545
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
4646
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
47+
import io.element.android.libraries.architecture.Async
4748
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
4849
import io.element.android.libraries.core.mimetype.MimeTypes
50+
import io.element.android.libraries.designsystem.components.avatar.AvatarData
51+
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
4952
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
5053
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
5154
import io.element.android.libraries.matrix.api.media.MediaSource
@@ -83,9 +86,16 @@ class MessagesPresenterTest {
8386
moleculeFlow(RecompositionMode.Immediate) {
8487
presenter.present()
8588
}.test {
86-
skipItems(1)
87-
val initialState = awaitItem()
89+
val initialState = consumeItemsUntilTimeout().last()
8890
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
91+
assertThat(initialState.roomName).isEqualTo(Async.Success(""))
92+
assertThat(initialState.roomAvatar).isEqualTo(Async.Success(AvatarData(id = A_ROOM_ID.value, name = "", size = AvatarSize.TimelineRoom)))
93+
assertThat(initialState.userHasPermissionToSendMessage).isTrue()
94+
assertThat(initialState.userHasPermissionToRedact).isFalse()
95+
assertThat(initialState.hasNetworkConnection).isTrue()
96+
assertThat(initialState.snackbarMessage).isNull()
97+
assertThat(initialState.inviteProgress).isEqualTo(Async.Uninitialized)
98+
assertThat(initialState.showReinvitePrompt).isFalse()
8999
}
90100
}
91101

@@ -531,6 +541,19 @@ class MessagesPresenterTest {
531541
}
532542
}
533543

544+
@Test
545+
fun `present - permission to redact`() = runTest {
546+
val matrixRoom = FakeMatrixRoom(canRedact = true)
547+
val presenter = createMessagePresenter(matrixRoom = matrixRoom)
548+
moleculeFlow(RecompositionMode.Immediate) {
549+
presenter.present()
550+
}.test {
551+
val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedact }.last()
552+
assertThat(initialState.userHasPermissionToRedact).isTrue()
553+
cancelAndIgnoreRemainingEvents()
554+
}
555+
}
556+
534557
private fun TestScope.createMessagePresenter(
535558
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
536559
matrixRoom: MatrixRoom = FakeMatrixRoom(),

features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class ActionListPresenterTest {
5656
}.test {
5757
val initialState = awaitItem()
5858
val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent)
59-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
59+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
6060
// val loadingState = awaitItem()
6161
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
6262
val successState = awaitItem()
@@ -81,7 +81,7 @@ class ActionListPresenterTest {
8181
}.test {
8282
val initialState = awaitItem()
8383
val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent)
84-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
84+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
8585
// val loadingState = awaitItem()
8686
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
8787
val successState = awaitItem()
@@ -109,7 +109,7 @@ class ActionListPresenterTest {
109109
isMine = false,
110110
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
111111
)
112-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
112+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
113113
// val loadingState = awaitItem()
114114
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
115115
val successState = awaitItem()
@@ -130,6 +130,37 @@ class ActionListPresenterTest {
130130
}
131131
}
132132

133+
@Test
134+
fun `present - compute for others message and can redact`() = runTest {
135+
val presenter = anActionListPresenter(isBuildDebuggable = true)
136+
moleculeFlow(RecompositionMode.Immediate) {
137+
presenter.present()
138+
}.test {
139+
val initialState = awaitItem()
140+
val messageEvent = aMessageEvent(
141+
isMine = false,
142+
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
143+
)
144+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, true))
145+
val successState = awaitItem()
146+
assertThat(successState.target).isEqualTo(
147+
ActionListState.Target.Success(
148+
messageEvent,
149+
persistentListOf(
150+
TimelineItemAction.Reply,
151+
TimelineItemAction.Forward,
152+
TimelineItemAction.Copy,
153+
TimelineItemAction.Developer,
154+
TimelineItemAction.ReportContent,
155+
TimelineItemAction.Redact,
156+
)
157+
)
158+
)
159+
initialState.eventSink.invoke(ActionListEvents.Clear)
160+
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
161+
}
162+
}
163+
133164
@Test
134165
fun `present - compute for my message`() = runTest {
135166
val presenter = anActionListPresenter(isBuildDebuggable = true)
@@ -141,7 +172,7 @@ class ActionListPresenterTest {
141172
isMine = true,
142173
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
143174
)
144-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
175+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
145176
// val loadingState = awaitItem()
146177
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
147178
val successState = awaitItem()
@@ -174,7 +205,7 @@ class ActionListPresenterTest {
174205
isMine = true,
175206
content = aTimelineItemImageContent(),
176207
)
177-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
208+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
178209
// val loadingState = awaitItem()
179210
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
180211
val successState = awaitItem()
@@ -205,7 +236,7 @@ class ActionListPresenterTest {
205236
isMine = true,
206237
content = aTimelineItemStateEventContent(),
207238
)
208-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
239+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false))
209240
// val loadingState = awaitItem()
210241
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
211242
val successState = awaitItem()
@@ -234,7 +265,7 @@ class ActionListPresenterTest {
234265
isMine = true,
235266
content = aTimelineItemStateEventContent(),
236267
)
237-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
268+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false))
238269
// val loadingState = awaitItem()
239270
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
240271
val successState = awaitItem()
@@ -262,7 +293,7 @@ class ActionListPresenterTest {
262293
isMine = true,
263294
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
264295
)
265-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
296+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
266297
// val loadingState = awaitItem()
267298
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
268299
val successState = awaitItem()
@@ -299,10 +330,10 @@ class ActionListPresenterTest {
299330
content = TimelineItemRedactedContent,
300331
)
301332

302-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
333+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
303334
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
304335

305-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent))
336+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, false))
306337
awaitItem().run {
307338
assertThat(target).isEqualTo(ActionListState.Target.None)
308339
assertThat(displayEmojiReactions).isFalse()
@@ -323,7 +354,7 @@ class ActionListPresenterTest {
323354
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
324355
)
325356

326-
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
357+
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
327358
val successState = awaitItem()
328359
assertThat(successState.target).isEqualTo(
329360
ActionListState.Target.Success(

libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ interface MatrixRoom : Closeable {
105105

106106
suspend fun canUserInvite(userId: UserId): Result<Boolean>
107107

108+
suspend fun canUserRedact(userId: UserId): Result<Boolean>
109+
108110
suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean>
109111

110112
suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean>

libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ suspend fun MatrixRoom.canSendState(type: StateEventType): Result<Boolean> = can
3434
* Shortcut for calling [MatrixRoom.canUserSendMessage] with our own user.
3535
*/
3636
suspend fun MatrixRoom.canSendMessage(type: MessageEventType): Result<Boolean> = canUserSendMessage(sessionId, type)
37+
38+
/**
39+
* Shortcut for calling [MatrixRoom.canUserRedact] with our own user.
40+
*/
41+
suspend fun MatrixRoom.canRedact(): Result<Boolean> = canUserRedact(sessionId)
42+

libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,12 @@ class RustMatrixRoom(
250250
}
251251
}
252252

253+
override suspend fun canUserRedact(userId: UserId): Result<Boolean> {
254+
return runCatching {
255+
innerRoom.canUserRedact(userId.value)
256+
}
257+
}
258+
253259
override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> {
254260
return runCatching {
255261
innerRoom.canUserSendState(userId.value, type.map())

libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class FakeMatrixRoom(
5656
override val joinedMemberCount: Long = 123L,
5757
override val activeMemberCount: Long = 234L,
5858
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
59+
canRedact: Boolean = false,
5960
) : MatrixRoom {
6061

6162
private var ignoreResult: Result<Unit> = Result.success(Unit)
@@ -66,6 +67,7 @@ class FakeMatrixRoom(
6667
private var joinRoomResult = Result.success(Unit)
6768
private var inviteUserResult = Result.success(Unit)
6869
private var canInviteResult = Result.success(true)
70+
private var canRedactResult = Result.success(canRedact)
6971
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
7072
private val canSendEventResults = mutableMapOf<MessageEventType, Result<Boolean>>()
7173
private var sendMediaResult = Result.success(Unit)
@@ -207,6 +209,10 @@ class FakeMatrixRoom(
207209
return canInviteResult
208210
}
209211

212+
override suspend fun canUserRedact(userId: UserId): Result<Boolean> {
213+
return canRedactResult
214+
}
215+
210216
override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> {
211217
return canSendStateResults[type] ?: Result.failure(IllegalStateException("No fake answer"))
212218
}

libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.runtime.State
2121
import androidx.compose.runtime.produceState
2222
import io.element.android.libraries.matrix.api.room.MatrixRoom
2323
import io.element.android.libraries.matrix.api.room.MessageEventType
24+
import io.element.android.libraries.matrix.api.room.powerlevels.canRedact
2425
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
2526

2627
@Composable
@@ -30,3 +31,10 @@ fun MatrixRoom.canSendMessageAsState(type: MessageEventType, updateKey: Long): S
3031
}
3132
}
3233

34+
@Composable
35+
fun MatrixRoom.canRedactAsState(updateKey: Long): State<Boolean> {
36+
return produceState(initialValue = false, key1 = updateKey) {
37+
value = canRedact().getOrElse { false }
38+
}
39+
}
40+

0 commit comments

Comments
 (0)