From 1d720573919ebcb2f29cf47830e604f2a5c5148b Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 6 Nov 2025 20:07:01 +0100 Subject: [PATCH 1/9] change(room members): show ModerationView for banned members too --- .../roomdetails/impl/members/RoomMemberListPresenter.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 90619d2e2db..a0eca8039a8 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject -import io.element.android.features.roommembermoderation.api.ModerationAction import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.architecture.AsyncData @@ -164,11 +163,7 @@ class RoomMemberListPresenter( is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query is RoomMemberListEvents.RoomMemberSelected -> - if (event.roomMember.membership == RoomMembershipState.BAN) { - roomModerationState.eventSink(RoomMemberModerationEvents.ProcessAction(ModerationAction.UnbanUser, event.roomMember.toMatrixUser())) - } else { - roomModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.roomMember.toMatrixUser())) - } + roomModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.roomMember.toMatrixUser())) } } From 8d76dd916126abe29add8afe50af926a0edfd58c Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 6 Nov 2025 20:10:07 +0100 Subject: [PATCH 2/9] change(room members): add reason to unban --- .../impl/InternalRoomMemberModerationEvents.kt | 2 +- .../impl/RoomMemberModerationPresenter.kt | 8 ++++++-- .../impl/RoomMemberModerationView.kt | 13 ++++++++----- .../impl/RoomMemberModerationPresenterTest.kt | 2 +- .../impl/RoomMemberModerationViewTest.kt | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationEvents.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationEvents.kt index 902c2bd21ff..1bdcfb88f94 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationEvents.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationEvents.kt @@ -12,6 +12,6 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration sealed interface InternalRoomMemberModerationEvents : RoomMemberModerationEvents { data class DoKickUser(val reason: String) : InternalRoomMemberModerationEvents data class DoBanUser(val reason: String) : InternalRoomMemberModerationEvents - data object DoUnbanUser : InternalRoomMemberModerationEvents + data class DoUnbanUser(val reason: String) : InternalRoomMemberModerationEvents data object Reset : InternalRoomMemberModerationEvents } diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt index 995baa069b3..9ff6b0b0de2 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt @@ -118,7 +118,7 @@ class RoomMemberModerationPresenter( } is InternalRoomMemberModerationEvents.DoUnbanUser -> { selectedUser?.let { - coroutineScope.unbanUser(it.userId, unbanUserAsyncAction) + coroutineScope.unbanUser(it.userId, event.reason, unbanUserAsyncAction) } selectedUser = null } @@ -197,10 +197,14 @@ class RoomMemberModerationPresenter( private fun CoroutineScope.unbanUser( userId: UserId, + reason: String, unbanUserAction: MutableState>, ) = runActionAndWaitForMembershipChange(unbanUserAction) { analyticsService.capture(RoomModeration(RoomModeration.Action.UnbanMember)) - room.unbanUser(userId = userId) + room.unbanUser( + userId = userId, + reason = reason.takeIf { it.isNotBlank() }, + ) } private fun CoroutineScope.runActionAndWaitForMembershipChange( diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt index 248b6cd02b8..b7d3dfee958 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt @@ -166,18 +166,21 @@ private fun RoomMemberAsyncActions( } when (val action = state.unbanUserAsyncAction) { is AsyncAction.Confirming -> { - ConfirmationDialog( + TextFieldDialog( title = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_title), - content = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_description), submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_action), - onSubmitClick = { + onSubmit = { reason -> val userDisplayName = selectedUser?.getBestName().orEmpty() asyncIndicatorState.enqueue { AsyncIndicator.Loading(text = stringResource(R.string.screen_bottom_sheet_manage_room_member_unbanning_user, userDisplayName)) } - state.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser) + state.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser(reason = reason)) }, - onDismiss = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, + onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, + placeholder = stringResource(id = CommonStrings.common_reason), + label = stringResource(id = CommonStrings.common_reason), + content = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_description), + value = "", ) } is AsyncAction.Failure -> { diff --git a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt index 40e09b36205..423e9f6621f 100644 --- a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt +++ b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt @@ -290,7 +290,7 @@ class RoomMemberModerationPresenterTest { ) ) skipItems(2) - initialState.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser) + initialState.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser("Reason")) skipItems(1) val loadingState = awaitState() assertThat(loadingState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) diff --git a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt index fc1b8155622..2ac8a025bbc 100644 --- a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt +++ b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt @@ -181,7 +181,7 @@ class RoomMemberModerationViewTest { ), ) rule.pressTag(TestTags.dialogPositive.value) - eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.DoUnbanUser) + eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.DoUnbanUser("")) } @Test From 3cc455c1aaeb790aa1b1487c0a297de01d0035f5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 7 Nov 2025 20:15:18 +0100 Subject: [PATCH 3/9] change(room members): makes sure to subscribe to timeline items changes --- .../libraries/matrix/api/timeline/Timeline.kt | 2 + .../matrix/impl/room/JoinedRustRoom.kt | 22 ++- .../timeline/MatrixTimelineDiffProcessor.kt | 142 +++++++++++++----- .../matrix/impl/timeline/RustTimeline.kt | 21 ++- .../matrix/impl/timeline/TimelineDiffExt.kt | 32 ---- .../impl/timeline/TimelineItemsSubscriber.kt | 6 - .../MatrixTimelineDiffProcessorTest.kt | 12 +- .../matrix/impl/timeline/RustTimelineTest.kt | 2 - .../timeline/TimelineItemsSubscriberTest.kt | 28 ++-- .../matrix/test/timeline/FakeTimeline.kt | 1 + 10 files changed, 162 insertions(+), 106 deletions(-) delete mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineDiffExt.kt diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 0306bd5fe28..307710cef4e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -54,6 +54,7 @@ interface Timeline : AutoCloseable { val mode: Mode val membershipChangeEventReceived: Flow + val onSyncedEventReceived: Flow suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result suspend fun markAsRead(receiptType: ReceiptType): Result suspend fun paginate(direction: PaginationDirection): Result @@ -233,4 +234,5 @@ interface Timeline : AutoCloseable { * Get the latest event id of the timeline. */ suspend fun getLatestEventId(): Result + } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 4622073950e..54d4fe1cf52 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener @@ -91,8 +93,6 @@ class JoinedRustRoom( private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) private val innerRoom = baseRoom.innerRoom - override val syncUpdateFlow = MutableStateFlow(0L) - override val roomTypingMembersFlow: Flow> = mxCallbackFlow { val initial = emptyList() channel.trySend(initial) @@ -135,11 +135,21 @@ class JoinedRustRoom( override val roomNotificationSettingsStateFlow = MutableStateFlow(RoomNotificationSettingsState.Unknown) - override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) { - syncUpdateFlow.value = systemClock.epochMillis() - } + override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) + + override val syncUpdateFlow = liveTimeline + .onSyncedEventReceived.map { systemClock.epochMillis() } + .stateIn( + scope = roomCoroutineScope, + started = WhileSubscribed(), + initialValue = systemClock.epochMillis(), + ) init { + subscribeToRoomMembersChange() + } + + private fun subscribeToRoomMembersChange() { val powerLevelChanges = roomInfoFlow.map { it.roomPowerLevels }.distinctUntilChanged() val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) } combine(membershipChanges, powerLevelChanges) { _, _ -> } @@ -478,7 +488,6 @@ class JoinedRustRoom( private fun InnerTimeline.map( mode: Timeline.Mode, - onNewSyncedEvent: () -> Unit = {}, ): Timeline { val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$this") return RustTimeline( @@ -489,7 +498,6 @@ class JoinedRustRoom( coroutineScope = timelineCoroutineScope, dispatcher = roomDispatcher, roomContentForwarder = roomContentForwarder, - onNewSyncedEvent = onNewSyncedEvent, ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt index eb27f2d667c..23ce6805638 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt @@ -7,9 +7,10 @@ package io.element.android.libraries.matrix.impl.timeline +import androidx.compose.ui.util.fastForEach import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent -import kotlinx.coroutines.flow.Flow +import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex @@ -20,58 +21,60 @@ import timber.log.Timber internal class MatrixTimelineDiffProcessor( private val timelineItems: MutableSharedFlow>, - private val timelineItemFactory: MatrixTimelineItemMapper, + private val membershipChangeEventReceivedFlow: MutableSharedFlow, + private val syncedEventReceivedFlow: MutableSharedFlow, + private val timelineItemMapper: MatrixTimelineItemMapper, ) { private val mutex = Mutex() - private val _membershipChangeEventReceived = MutableSharedFlow(extraBufferCapacity = 1) - val membershipChangeEventReceived: Flow = _membershipChangeEventReceived - suspend fun postDiffs(diffs: List) { - updateTimelineItems { + mutex.withLock { Timber.v("Update timeline items from postDiffs (with ${diffs.size} items) on ${Thread.currentThread()}") - diffs.forEach { diff -> - applyDiff(diff) + val result = processDiffs(diffs) + timelineItems.emit(result.items()) + if (result.hasNewEventsFromSync()) { + syncedEventReceivedFlow.emit(Unit) + } + if (result.hasMembershipChangeEventFromSync()) { + membershipChangeEventReceivedFlow.emit(Unit) } } } - private suspend fun updateTimelineItems(block: MutableList.() -> Unit) = - mutex.withLock { - val mutableTimelineItems = if (timelineItems.replayCache.isNotEmpty()) { - timelineItems.first().toMutableList() - } else { - mutableListOf() - } - block(mutableTimelineItems) - timelineItems.tryEmit(mutableTimelineItems) + private suspend fun processDiffs(diffs: List): DiffingResult { + val mutableTimelineItems = if (timelineItems.replayCache.isNotEmpty()) { + timelineItems.first().toMutableList() + } else { + mutableListOf() } + val result = DiffingResult(items = mutableTimelineItems) + diffs.forEach { diff -> + result.applyDiff(diff) + } + return result + } - private fun MutableList.applyDiff(diff: TimelineDiff) { + private fun DiffingResult.applyDiff(diff: TimelineDiff) { when (diff) { is TimelineDiff.Append -> { - val items = diff.values.map { it.asMatrixTimelineItem() } - addAll(items) + diff.values.fastForEach { item -> + add(item.map()) + } } is TimelineDiff.PushBack -> { - val item = diff.value.asMatrixTimelineItem() - if (item is MatrixTimelineItem.Event && item.event.content is RoomMembershipContent) { - // TODO - This is a temporary solution to notify the room screen about membership changes - // Ideally, this should be implemented by the Rust SDK - _membershipChangeEventReceived.tryEmit(Unit) - } + val item = diff.value.map() add(item) } is TimelineDiff.PushFront -> { - val item = diff.value.asMatrixTimelineItem() + val item = diff.value.map() add(0, item) } is TimelineDiff.Set -> { - val item = diff.value.asMatrixTimelineItem() + val item = diff.value.map() set(diff.index.toInt(), item) } is TimelineDiff.Insert -> { - val item = diff.value.asMatrixTimelineItem() + val item = diff.value.map() add(diff.index.toInt(), item) } is TimelineDiff.Remove -> { @@ -79,25 +82,92 @@ internal class MatrixTimelineDiffProcessor( } is TimelineDiff.Reset -> { clear() - val items = diff.values.map { it.asMatrixTimelineItem() } - addAll(items) + diff.values.fastForEach { item -> + add(item.map()) + } } TimelineDiff.PopFront -> { - removeFirstOrNull() + removeFirst() } TimelineDiff.PopBack -> { - removeLastOrNull() + removeLast() } TimelineDiff.Clear -> { clear() } is TimelineDiff.Truncate -> { - subList(diff.length.toInt(), size).clear() + truncate(diff.length.toInt()) + } + } + } + + private fun TimelineItem.map(): MatrixTimelineItem { + return timelineItemMapper.map(this) + } +} + +private class DiffingResult( + private val items: MutableList, + private var hasNewEventsFromSync: Boolean = false, + private var hasMembershipChangeEventFromSync: Boolean = false, +) { + + fun items(): List = items + fun hasNewEventsFromSync(): Boolean = hasNewEventsFromSync + fun hasMembershipChangeEventFromSync(): Boolean = hasMembershipChangeEventFromSync + + fun add(item: MatrixTimelineItem) { + processItem(item) + items.add(item) + } + + fun add(index: Int, item: MatrixTimelineItem) { + processItem(item) + items.add(index, item) + } + + fun set(index: Int, item: MatrixTimelineItem) { + processItem(item) + items[index] = item + } + + fun removeAt(index: Int) { + items.removeAt(index) + } + + fun removeFirst() { + items.removeFirstOrNull() + } + + fun removeLast() { + items.removeLastOrNull() + } + + fun truncate(length: Int) { + items.subList(length, items.size).clear() + } + + fun clear() { + items.clear() + } + + private fun processItem(item: MatrixTimelineItem) { + if (skipProcessing()) return + when (item) { + is MatrixTimelineItem.Event -> { + if (item.event.origin == TimelineItemEventOrigin.SYNC) { + hasNewEventsFromSync = true + when (item.event.content) { + is RoomMembershipContent -> hasMembershipChangeEventFromSync = true + else -> Unit + } + } } + else -> Unit } } - private fun TimelineItem.asMatrixTimelineItem(): MatrixTimelineItem { - return timelineItemFactory.map(this) + private fun skipProcessing(): Boolean { + return hasNewEventsFromSync && hasMembershipChangeEventFromSync } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index ecf37dd9bfa..925abda6d6e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -80,16 +80,18 @@ private const val PAGINATION_SIZE = 50 class RustTimeline( private val inner: InnerTimeline, override val mode: Timeline.Mode, - systemClock: SystemClock, + private val systemClock: SystemClock, private val joinedRoom: JoinedRoom, private val coroutineScope: CoroutineScope, private val dispatcher: CoroutineDispatcher, private val roomContentForwarder: RoomContentForwarder, - onNewSyncedEvent: () -> Unit, ) : Timeline { private val _timelineItems: MutableSharedFlow> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) + private val _membershipChangeEventReceived = MutableSharedFlow(extraBufferCapacity = 1) + private val _onSyncedEventReceived: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val timelineEventContentMapper = TimelineEventContentMapper() private val inReplyToMapper = InReplyToMapper(timelineEventContentMapper) private val timelineItemMapper = MatrixTimelineItemMapper( @@ -98,18 +100,19 @@ class RustTimeline( virtualTimelineItemMapper = VirtualTimelineItemMapper(), eventTimelineItemMapper = EventTimelineItemMapper( contentMapper = timelineEventContentMapper - ) + ), ) private val timelineDiffProcessor = MatrixTimelineDiffProcessor( timelineItems = _timelineItems, - timelineItemFactory = timelineItemMapper, + membershipChangeEventReceivedFlow = _membershipChangeEventReceived, + syncedEventReceivedFlow = _onSyncedEventReceived, + timelineItemMapper = timelineItemMapper, ) private val timelineItemsSubscriber = TimelineItemsSubscriber( timeline = inner, timelineCoroutineScope = coroutineScope, timelineDiffProcessor = timelineDiffProcessor, dispatcher = dispatcher, - onNewSyncedEvent = onNewSyncedEvent, ) private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode) @@ -151,7 +154,13 @@ class RustTimeline( .launchIn(this) } - override val membershipChangeEventReceived: Flow = timelineDiffProcessor.membershipChangeEventReceived + override val membershipChangeEventReceived: Flow = _membershipChangeEventReceived + .onStart { timelineItemsSubscriber.subscribeIfNeeded() } + .onCompletion { timelineItemsSubscriber.unsubscribeIfNeeded() } + + override val onSyncedEventReceived: Flow = _onSyncedEventReceived + .onStart { timelineItemsSubscriber.subscribeIfNeeded() } + .onCompletion { timelineItemsSubscriber.unsubscribeIfNeeded() } override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result = withContext(dispatcher) { runCatchingExceptions { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineDiffExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineDiffExt.kt deleted file mode 100644 index 2da28399f61..00000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineDiffExt.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2023, 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.matrix.impl.timeline - -import org.matrix.rustcomponents.sdk.TimelineDiff -import org.matrix.rustcomponents.sdk.TimelineItem -import uniffi.matrix_sdk_ui.EventItemOrigin - -/** - * Tries to get an event origin from the TimelineDiff. - * If there is multiple events in the diff, uses the first one as it should be a good indicator. - */ -internal fun TimelineDiff.eventOrigin(): EventItemOrigin? { - return when (this) { - is TimelineDiff.Append -> values.firstOrNull()?.eventOrigin() - is TimelineDiff.PushBack -> value.eventOrigin() - is TimelineDiff.PushFront -> value.eventOrigin() - is TimelineDiff.Set -> value.eventOrigin() - is TimelineDiff.Insert -> value.eventOrigin() - is TimelineDiff.Reset -> values.firstOrNull()?.eventOrigin() - else -> null - } -} - -private fun TimelineItem.eventOrigin(): EventItemOrigin? { - return asEvent()?.origin -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt index 3d95d660ff0..dd28e714953 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriber.kt @@ -11,13 +11,11 @@ import io.element.android.libraries.core.coroutine.childScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.matrix.rustcomponents.sdk.Timeline -import uniffi.matrix_sdk_ui.EventItemOrigin /** * This class is responsible for subscribing to a timeline and post the items/diffs to the timelineDiffProcessor. @@ -28,7 +26,6 @@ internal class TimelineItemsSubscriber( dispatcher: CoroutineDispatcher, private val timeline: Timeline, private val timelineDiffProcessor: MatrixTimelineDiffProcessor, - private val onNewSyncedEvent: () -> Unit, ) { private var subscriptionCount = 0 private val mutex = Mutex() @@ -43,9 +40,6 @@ internal class TimelineItemsSubscriber( if (subscriptionCount == 0) { timeline.timelineDiffFlow() .onEach { diffs -> - if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) { - onNewSyncedEvent() - } timelineDiffProcessor.postDiffs(diffs) } .launchIn(coroutineScope) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt index 67f4487ef48..bbc9ed3abe4 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt @@ -168,10 +168,12 @@ class MatrixTimelineDiffProcessorTest { } internal fun TestScope.createMatrixTimelineDiffProcessor( - timelineItems: MutableSharedFlow>, -): MatrixTimelineDiffProcessor { + timelineItems: MutableSharedFlow> = MutableSharedFlow(), + membershipChangeEventReceivedFlow: MutableSharedFlow = MutableSharedFlow(), + syncedEventReceivedFlow: MutableSharedFlow = MutableSharedFlow(), + ): MatrixTimelineDiffProcessor { val timelineEventContentMapper = TimelineEventContentMapper() - val timelineItemMapper = MatrixTimelineItemMapper( + val timelineItemFactory = MatrixTimelineItemMapper( fetchDetailsForEvent = { _ -> Result.success(Unit) }, coroutineScope = this, virtualTimelineItemMapper = VirtualTimelineItemMapper(), @@ -181,6 +183,8 @@ internal fun TestScope.createMatrixTimelineDiffProcessor( ) return MatrixTimelineDiffProcessor( timelineItems = timelineItems, - timelineItemFactory = timelineItemMapper, + membershipChangeEventReceivedFlow = membershipChangeEventReceivedFlow, + syncedEventReceivedFlow = syncedEventReceivedFlow, + timelineItemMapper = timelineItemFactory, ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt index 1cad54926f8..14afd46bc50 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt @@ -98,7 +98,6 @@ private fun TestScope.createRustTimeline( coroutineScope: CoroutineScope = backgroundScope, dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io, roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeFfiRoomListService()), - onNewSyncedEvent: () -> Unit = {}, ): RustTimeline { return RustTimeline( inner = inner, @@ -108,6 +107,5 @@ private fun TestScope.createRustTimeline( coroutineScope = coroutineScope, dispatcher = dispatcher, roomContentForwarder = roomContentForwarder, - onNewSyncedEvent = onNewSyncedEvent, ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt index 752a3096398..208031f4dd1 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt @@ -13,8 +13,6 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.impl.fixtures.factories.aRustEventTimelineItem import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimeline import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineItem -import io.element.android.tests.testutils.lambda.lambdaError -import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.StandardTestDispatcher @@ -35,9 +33,12 @@ class TimelineItemsSubscriberTest { val timelineItems: MutableSharedFlow> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) val timeline = FakeFfiTimeline() + val diffProcessor = createMatrixTimelineDiffProcessor( + timelineItems = timelineItems, + ) val timelineItemsSubscriber = createTimelineItemsSubscriber( timeline = timeline, - timelineItems = timelineItems, + timelineDiffProcessor = diffProcessor, ) timelineItems.test { timelineItemsSubscriber.subscribeIfNeeded() @@ -56,9 +57,12 @@ class TimelineItemsSubscriberTest { val timelineItems: MutableSharedFlow> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) val timeline = FakeFfiTimeline() + val diffProcessor = createMatrixTimelineDiffProcessor( + timelineItems = timelineItems, + ) val timelineItemsSubscriber = createTimelineItemsSubscriber( timeline = timeline, - timelineItems = timelineItems, + timelineDiffProcessor = diffProcessor, ) timelineItems.test { timelineItemsSubscriber.subscribeIfNeeded() @@ -73,15 +77,16 @@ class TimelineItemsSubscriberTest { @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test - fun `when timeline emits an item with SYNC origin, the callback onNewSyncedEvent is invoked`() = runTest { + fun `when timeline emits an item with SYNC origin`() = runTest { val timelineItems: MutableSharedFlow> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) val timeline = FakeFfiTimeline() - val onNewSyncedEventRecorder = lambdaRecorder { } + val diffProcessor = createMatrixTimelineDiffProcessor( + timelineItems = timelineItems, + ) val timelineItemsSubscriber = createTimelineItemsSubscriber( timeline = timeline, - timelineItems = timelineItems, - onNewSyncedEvent = onNewSyncedEventRecorder, + timelineDiffProcessor = diffProcessor, ) timelineItems.test { timelineItemsSubscriber.subscribeIfNeeded() @@ -100,7 +105,6 @@ class TimelineItemsSubscriberTest { assertThat(final).isNotEmpty() timelineItemsSubscriber.unsubscribeIfNeeded() } - onNewSyncedEventRecorder.assertions().isCalledOnce() } @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @@ -116,14 +120,12 @@ class TimelineItemsSubscriberTest { private fun TestScope.createTimelineItemsSubscriber( timeline: Timeline = FakeFfiTimeline(), - timelineItems: MutableSharedFlow> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE), - onNewSyncedEvent: () -> Unit = { lambdaError() }, + timelineDiffProcessor: MatrixTimelineDiffProcessor = createMatrixTimelineDiffProcessor(), ): TimelineItemsSubscriber { return TimelineItemsSubscriber( timelineCoroutineScope = backgroundScope, dispatcher = StandardTestDispatcher(testScheduler), timeline = timeline, - timelineDiffProcessor = createMatrixTimelineDiffProcessor(timelineItems), - onNewSyncedEvent = onNewSyncedEvent, + timelineDiffProcessor = timelineDiffProcessor, ) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index dfbe5d52ae7..234d8b6aeca 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -47,6 +47,7 @@ class FakeTimeline( ) ), override val membershipChangeEventReceived: Flow = MutableSharedFlow(), + override val onSyncedEventReceived: Flow = MutableSharedFlow(), private val cancelSendResult: (TransactionId) -> Result = { lambdaError() }, override val mode: Timeline.Mode = Timeline.Mode.Live, private val markAsReadResult: (ReceiptType) -> Result = { lambdaError() }, From 9618e9ad51c9a6d56a57cfdaff9580c01b027aa5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 13 Nov 2025 16:00:07 +0100 Subject: [PATCH 4/9] quality : format code --- .../roommembermoderation/impl/RoomMemberModerationView.kt | 1 - .../element/android/libraries/matrix/api/timeline/Timeline.kt | 1 - .../android/libraries/matrix/impl/room/JoinedRustRoom.kt | 4 ++-- .../matrix/impl/timeline/MatrixTimelineDiffProcessor.kt | 1 - 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt index 434bf50d7df..b146f728f9d 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt @@ -40,7 +40,6 @@ import io.element.android.libraries.designsystem.components.async.rememberAsyncI import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.TextFieldDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index a6fd494fb83..500d9f31914 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -235,5 +235,4 @@ interface Timeline : AutoCloseable { * Get the latest event id of the timeline. */ suspend fun getLatestEventId(): Result - } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 03527645de6..0148b45aa83 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -138,8 +138,8 @@ class JoinedRustRoom( override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) - override val syncUpdateFlow = liveTimeline - .onSyncedEventReceived.map { systemClock.epochMillis() } + override val syncUpdateFlow = liveTimeline.onSyncedEventReceived + .map { systemClock.epochMillis() } .stateIn( scope = roomCoroutineScope, started = WhileSubscribed(), diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt index e2a37860547..12a25c3169d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt @@ -112,7 +112,6 @@ private class DiffingResult( private var hasNewEventsFromSync: Boolean = false, private var hasMembershipChangeEventFromSync: Boolean = false, ) { - fun items(): List = items fun hasNewEventsFromSync(): Boolean = hasNewEventsFromSync fun hasMembershipChangeEventFromSync(): Boolean = hasMembershipChangeEventFromSync From 51959703b1c1bacbf2b8c6376ec82c28a9bb29f4 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 13 Nov 2025 16:48:43 +0100 Subject: [PATCH 5/9] change(room members): remove useless call to updateMembers --- .../impl/roles/ChangeRolesPresenter.kt | 49 ++++++++----------- .../impl/members/RoomMemberListPresenter.kt | 14 ++---- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt index 3207543e0f6..2b02cdfdb29 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt @@ -25,6 +25,7 @@ import im.vector.app.features.analytics.plan.RoomModeration import io.element.android.features.rolesandpermissions.impl.RoomMemberListDataSource import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.di.annotations.RoomCoroutineScope @@ -193,37 +194,27 @@ class ChangeRolesPresenter( selectedUsers: MutableState>, saveState: MutableState>, ) = launch { - saveState.value = AsyncAction.Loading - - val toAdd = selectedUsers.value - usersWithRole - val toRemove = usersWithRole - selectedUsers.value - - val changes: List = buildList { - for (selectedUser in toAdd) { - analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, role.toAnalyticsMemberRole())) - add(UserRoleChange(selectedUser.userId, role)) - } - for (selectedUser in toRemove) { - analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User)) - add(UserRoleChange(selectedUser.userId, RoomMember.Role.User)) + runUpdatingState(saveState) { + val toAdd = selectedUsers.value - usersWithRole + val toRemove = usersWithRole - selectedUsers.value + val changes: List = buildList { + for (selectedUser in toAdd) { + analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, role.toAnalyticsMemberRole())) + add(UserRoleChange(selectedUser.userId, role)) + } + for (selectedUser in toRemove) { + analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User)) + add(UserRoleChange(selectedUser.userId, RoomMember.Role.User)) + } } + room.updateUsersRoles(changes).map { true } } - - room.updateUsersRoles(changes) - .onFailure { - saveState.value = AsyncAction.Failure(it) - } - .onSuccess { - // Asynchronously reload the room members - launch { room.updateMembers() } - saveState.value = AsyncAction.Success(true) - } } -} -internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) { - is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin - RoomMember.Role.Admin -> RoomModeration.Role.Administrator - RoomMember.Role.Moderator -> RoomModeration.Role.Moderator - RoomMember.Role.User -> RoomModeration.Role.User + internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) { + is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin + RoomMember.Role.Admin -> RoomModeration.Role.Administrator + RoomMember.Role.Moderator -> RoomModeration.Role.Moderator + RoomMember.Role.User -> RoomModeration.Role.User + } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 8f5053bd2a7..92705b17751 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -39,11 +39,8 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext @@ -55,9 +52,10 @@ class RoomMemberListPresenter( private val roomMembersModerationPresenter: Presenter, private val encryptionService: EncryptionService, ) : Presenter { + var roomMembers: AsyncData by mutableStateOf(AsyncData.Loading()) + @Composable override fun present(): RoomMemberListState { - var roomMembers: AsyncData by remember { mutableStateOf(AsyncData.Loading()) } var searchQuery by rememberSaveable { mutableStateOf("") } var searchResults by remember { mutableStateOf>>(SearchBarResultState.Initial()) @@ -77,13 +75,9 @@ class RoomMemberListPresenter( .launchIn(this) } - // Update the room members when the screen is loaded or the active member count changes + // Update the room members when the screen is loaded LaunchedEffect(Unit) { - room.roomInfoFlow.map { it.activeMembersCount } - .distinctUntilChanged() - .collectLatest { - room.updateMembers() - } + room.updateMembers() } LaunchedEffect(membersState, roomMemberIdentityStates) { From 3279684dc6833c8e3137dc6dabf8270cf9190877 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 13 Nov 2025 17:13:45 +0100 Subject: [PATCH 6/9] change(room members): moderation sheet design updates --- .../impl/RoomMemberModerationView.kt | 9 ++++++--- .../designsystem/components/dialogs/ListDialog.kt | 5 +++++ .../designsystem/components/dialogs/TextFieldDialog.kt | 6 +++++- .../designsystem/components/list/TextFieldListItem.kt | 7 +++++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt index b146f728f9d..1e7251b20b1 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt @@ -92,12 +92,13 @@ private fun RoomMemberAsyncActions( TextFieldDialog( title = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_title), submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_action), + destructiveSubmit = true, + minLines = 2, onSubmit = { reason -> state.eventSink(InternalRoomMemberModerationEvents.DoKickUser(reason = reason)) }, onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, placeholder = stringResource(id = CommonStrings.common_reason), - label = stringResource(id = CommonStrings.common_reason), content = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_description), value = "", ) @@ -131,12 +132,13 @@ private fun RoomMemberAsyncActions( TextFieldDialog( title = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_title), submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_action), + destructiveSubmit = true, + minLines = 2, onSubmit = { reason -> state.eventSink(InternalRoomMemberModerationEvents.DoBanUser(reason = reason)) }, onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, placeholder = stringResource(id = CommonStrings.common_reason), - label = stringResource(id = CommonStrings.common_reason), content = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_description), value = "", ) @@ -169,6 +171,8 @@ private fun RoomMemberAsyncActions( TextFieldDialog( title = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_title), submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_action), + destructiveSubmit = true, + minLines = 2, onSubmit = { reason -> val userDisplayName = selectedUser?.getBestName().orEmpty() asyncIndicatorState.enqueue { @@ -178,7 +182,6 @@ private fun RoomMemberAsyncActions( }, onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, placeholder = stringResource(id = CommonStrings.common_reason), - label = stringResource(id = CommonStrings.common_reason), content = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_description), value = "", ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index e9a8e627212..ce1afae93d3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -41,6 +41,7 @@ fun ListDialog( submitText: String = stringResource(CommonStrings.action_ok), enabled: Boolean = true, applyPaddingToContents: Boolean = true, + destructiveSubmit: Boolean = false, listItems: LazyListScope.() -> Unit, ) { val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let { @@ -65,6 +66,7 @@ fun ListDialog( enabled = enabled, listItems = listItems, applyPaddingToContents = applyPaddingToContents, + destructiveSubmit = destructiveSubmit, ) } } @@ -79,6 +81,7 @@ private fun ListDialogContent( title: String?, enabled: Boolean, applyPaddingToContents: Boolean, + destructiveSubmit: Boolean, subtitle: @Composable (() -> Unit)? = null, ) { SimpleAlertDialogContent( @@ -90,6 +93,7 @@ private fun ListDialogContent( onSubmitClick = onSubmitClick, enabled = enabled, applyPaddingToContents = applyPaddingToContents, + destructiveSubmit = destructiveSubmit, ) { // No start padding if padding is already applied to the content val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp @@ -120,6 +124,7 @@ internal fun ListDialogContentPreview() { cancelText = "Cancel", submitText = "Save", enabled = true, + destructiveSubmit = false, applyPaddingToContents = true, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt index cfc67988c85..aeaaa9f040f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt @@ -43,11 +43,13 @@ fun TextFieldDialog( validation: (String?) -> Boolean = { true }, onValidationErrorMessage: String? = null, autoSelectOnDisplay: Boolean = true, - maxLines: Int = 1, + minLines: Int = 1, + maxLines: Int = minLines, content: String? = null, label: String? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, submitText: String = stringResource(CommonStrings.action_ok), + destructiveSubmit: Boolean = false, ) { val focusRequester = remember { FocusRequester() } var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) { @@ -67,6 +69,7 @@ fun TextFieldDialog( onDismissRequest = onDismissRequest, enabled = canSubmit, submitText = submitText, + destructiveSubmit = destructiveSubmit, modifier = modifier, ) { if (content != null) { @@ -93,6 +96,7 @@ fun TextFieldDialog( onSubmit(textFieldContents.text) } }), + minLines = minLines, maxLines = maxLines, modifier = Modifier .fillMaxWidth() diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt index fc2c1c91cfd..e149af58909 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -26,7 +26,8 @@ fun TextFieldListItem( onTextChange: (String) -> Unit, modifier: Modifier = Modifier, error: String? = null, - maxLines: Int = 1, + minLines: Int = 1, + maxLines: Int = minLines, label: String? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, @@ -53,7 +54,8 @@ fun TextFieldListItem( onTextChange: (TextFieldValue) -> Unit, modifier: Modifier = Modifier, error: String? = null, - maxLines: Int = 1, + minLines: Int = 1, + maxLines: Int = minLines, label: String? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, @@ -68,6 +70,7 @@ fun TextFieldListItem( keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, maxLines = maxLines, + minLines = minLines, singleLine = maxLines == 1, modifier = modifier, ) From b33388bccd6c8a1b2018592e41313a5c04933cc3 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 13 Nov 2025 16:28:58 +0000 Subject: [PATCH 7/9] Update screenshots --- ...embermoderation.impl_RoomMemberModerationView_Day_4_en.png | 4 ++-- ...embermoderation.impl_RoomMemberModerationView_Day_6_en.png | 4 ++-- ...bermoderation.impl_RoomMemberModerationView_Night_4_en.png | 4 ++-- ...bermoderation.impl_RoomMemberModerationView_Night_6_en.png | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_4_en.png index edec0550cad..e8798e5d639 100644 --- a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4063555b9c55be91104f51d5696383aba383d2b7b8d6ff490058f0228e45f81 -size 30659 +oid sha256:e8f2b0a758dd5aa20f5e54a1c2e5b094dae58cecbd51b14e6badee0de0d4f47c +size 29661 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_6_en.png index fe4528f99c3..2fbf80638e3 100644 --- a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72ccd513cba258987e43054b269006625b76510efc26e4bc62ec2801f77be662 -size 28093 +oid sha256:3ca2cc0fb3cc31ee9d15b5c7d3c723b94fd4877bd25d419808d7c48f8e358496 +size 26527 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_4_en.png index 9c6513bfd19..4dd6dfae72d 100644 --- a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a7da5666d690d5d9a6be61225b169e4703626ebc15f2f8f4f3e98d2e322de39 -size 28831 +oid sha256:2708604aa1f4d6549b80ff388c45449c73577f39903810c438361fc068e440f9 +size 27813 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_6_en.png index 1bef3fc7be5..4f696f4f067 100644 --- a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28f71f91a53008d73ed1f7cf209c7732b6fe0f4d7f65c2cf3997dff99f27c5e4 -size 26402 +oid sha256:7aa3113919aad2ea9cb7fd6b5b4aee6c9aa1ab600ee6cd6d18f75cc70469d1cb +size 25084 From 5046148708de1b893326d563152cd2549f7d1a22 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 14 Nov 2025 12:27:45 +0100 Subject: [PATCH 8/9] change(room members): address PR reviews --- ...ternalRoomMemberModerationStateProvider.kt | 8 ++++++ .../matrix/impl/room/JoinedRustRoom.kt | 18 ++++++++----- .../timeline/MatrixTimelineDiffProcessor.kt | 26 +++++++++---------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt index 4d79abdabbb..d90f352cfa4 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt @@ -65,6 +65,14 @@ class InternalRoomMemberModerationStateProvider : PreviewParameterProvider): DiffingResult { - val mutableTimelineItems = if (timelineItems.replayCache.isNotEmpty()) { - timelineItems.first().toMutableList() + val timelineItems = if (timelineItems.replayCache.isNotEmpty()) { + timelineItems.first() } else { - mutableListOf() + emptyList() } - val result = DiffingResult(items = mutableTimelineItems) + val result = DiffingResult(timelineItems) diffs.forEach { diff -> result.applyDiff(diff) } @@ -107,14 +107,14 @@ internal class MatrixTimelineDiffProcessor( } } -private class DiffingResult( - private val items: MutableList, - private var hasNewEventsFromSync: Boolean = false, - private var hasMembershipChangeEventFromSync: Boolean = false, -) { +private class DiffingResult(initialItems: List) { + private val items = initialItems.toMutableList() + var hasNewEventsFromSync: Boolean = false + private set + var hasMembershipChangeEventFromSync: Boolean = false + private set + fun items(): List = items - fun hasNewEventsFromSync(): Boolean = hasNewEventsFromSync - fun hasMembershipChangeEventFromSync(): Boolean = hasMembershipChangeEventFromSync fun add(item: MatrixTimelineItem) { processItem(item) From f8fb069fbe4e6700e42fb666c278d54258df92af Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 14 Nov 2025 11:42:00 +0000 Subject: [PATCH 9/9] Update screenshots --- ...membermoderation.impl_RoomMemberModerationView_Day_8_en.png | 3 +++ ...membermoderation.impl_RoomMemberModerationView_Day_9_en.png | 3 +++ ...mbermoderation.impl_RoomMemberModerationView_Night_8_en.png | 3 +++ ...mbermoderation.impl_RoomMemberModerationView_Night_9_en.png | 3 +++ 4 files changed, 12 insertions(+) create mode 100644 tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_9_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_9_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_8_en.png new file mode 100644 index 00000000000..7742fa05dad --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb71efef332043ae5dd633a7bb877e3ef23123fb86550c0519d41ed05f737cf0 +size 27284 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_9_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_9_en.png new file mode 100644 index 00000000000..5c597300e46 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Day_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5 +size 3853 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_8_en.png new file mode 100644 index 00000000000..fa9d70ed6cf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2b314504a8f4aaf92828290706cd42ee5f88db1174b31d0652ea6bb8ddbf38b +size 25648 diff --git a/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_9_en.png b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_9_en.png new file mode 100644 index 00000000000..d47bf906641 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roommembermoderation.impl_RoomMemberModerationView_Night_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c +size 3664