Merge pull request #5728 from element-hq/feature/fga/members_improvements

Changes : member list improvements
This commit is contained in:
ganfra 2025-11-14 14:01:28 +01:00 committed by GitHub
commit 2120b5c5bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 254 additions and 176 deletions

View file

@ -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<ImmutableList<MatrixUser>>,
saveState: MutableState<AsyncAction<Boolean>>,
) = launch {
saveState.value = AsyncAction.Loading
val toAdd = selectedUsers.value - usersWithRole
val toRemove = usersWithRole - selectedUsers.value
val changes: List<UserRoleChange> = 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<UserRoleChange> = 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
}

View file

@ -18,7 +18,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
@ -40,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
@ -56,9 +52,10 @@ class RoomMemberListPresenter(
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
private val encryptionService: EncryptionService,
) : Presenter<RoomMemberListState> {
var roomMembers: AsyncData<RoomMembers> by mutableStateOf(AsyncData.Loading())
@Composable
override fun present(): RoomMemberListState {
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember {
mutableStateOf<SearchBarResultState<AsyncData<RoomMembers>>>(SearchBarResultState.Initial())
@ -78,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) {
@ -165,11 +158,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()))
}
}

View file

@ -13,6 +13,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
}

View file

@ -65,6 +65,14 @@ class InternalRoomMemberModerationStateProvider : PreviewParameterProvider<Inter
selectedUser = anAlice(),
banUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedUser = anAlice(),
unbanUserAsyncAction = AsyncAction.ConfirmingNoParams,
),
aRoomMembersModerationState(
selectedUser = anAlice(),
unbanUserAsyncAction = AsyncAction.Loading,
),
)
}

View file

@ -119,7 +119,7 @@ class RoomMemberModerationPresenter(
}
is InternalRoomMemberModerationEvents.DoUnbanUser -> {
selectedUser?.let {
coroutineScope.unbanUser(it.userId, unbanUserAsyncAction)
coroutineScope.unbanUser(it.userId, event.reason, unbanUserAsyncAction)
}
selectedUser = null
}
@ -198,10 +198,14 @@ class RoomMemberModerationPresenter(
private fun CoroutineScope.unbanUser(
userId: UserId,
reason: String,
unbanUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(unbanUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.UnbanMember))
room.unbanUser(userId = userId)
room.unbanUser(
userId = userId,
reason = reason.takeIf { it.isNotBlank() },
)
}
private fun <T> CoroutineScope.runActionAndWaitForMembershipChange(

View file

@ -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
@ -93,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 = "",
)
@ -132,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 = "",
)
@ -167,18 +168,22 @@ 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 = {
destructiveSubmit = true,
minLines = 2,
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),
content = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_description),
value = "",
)
}
is AsyncAction.Failure -> {

View file

@ -291,7 +291,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)

View file

@ -182,7 +182,7 @@ class RoomMemberModerationViewTest {
),
)
rule.pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.DoUnbanUser)
eventsRecorder.assertSingle(InternalRoomMemberModerationEvents.DoUnbanUser(""))
}
@Test

View file

@ -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,
)
}

View file

@ -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()

View file

@ -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,
)

View file

@ -55,6 +55,7 @@ interface Timeline : AutoCloseable {
val mode: Mode
val membershipChangeEventReceived: Flow<Unit>
val onSyncedEventReceived: Flow<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
suspend fun markAsRead(receiptType: ReceiptType): Result<Unit>
suspend fun paginate(direction: PaginationDirection): Result<Boolean>

View file

@ -51,13 +51,16 @@ 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
import kotlinx.coroutines.flow.flow
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
@ -92,8 +95,6 @@ class JoinedRustRoom(
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
private val innerRoom = baseRoom.innerRoom
override val syncUpdateFlow = MutableStateFlow(0L)
override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow {
val initial = emptyList<UserId>()
channel.trySend(initial)
@ -136,11 +137,24 @@ class JoinedRustRoom(
override val roomNotificationSettingsStateFlow = MutableStateFlow<RoomNotificationSettingsState>(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 = flow {
var counter = 0L
liveTimeline.onSyncedEventReceived.collect {
emit(++counter)
}
}.stateIn(
scope = roomCoroutineScope,
started = WhileSubscribed(),
initialValue = 0L,
)
init {
subscribeToRoomMembersChange()
}
private fun subscribeToRoomMembersChange() {
val powerLevelChanges = roomInfoFlow.map { it.roomPowerLevels }.distinctUntilChanged()
val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) }
combine(membershipChanges, powerLevelChanges) { _, _ -> }
@ -479,7 +493,6 @@ class JoinedRustRoom(
private fun InnerTimeline.map(
mode: Timeline.Mode,
onNewSyncedEvent: () -> Unit = {},
): Timeline {
val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$this")
return RustTimeline(
@ -490,7 +503,6 @@ class JoinedRustRoom(
coroutineScope = timelineCoroutineScope,
dispatcher = roomDispatcher,
roomContentForwarder = roomContentForwarder,
onNewSyncedEvent = onNewSyncedEvent,
)
}
}

View file

@ -8,9 +8,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
@ -21,58 +22,60 @@ import timber.log.Timber
internal class MatrixTimelineDiffProcessor(
private val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>>,
private val timelineItemFactory: MatrixTimelineItemMapper,
private val membershipChangeEventReceivedFlow: MutableSharedFlow<Unit>,
private val syncedEventReceivedFlow: MutableSharedFlow<Unit>,
private val timelineItemMapper: MatrixTimelineItemMapper,
) {
private val mutex = Mutex()
private val _membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val membershipChangeEventReceived: Flow<Unit> = _membershipChangeEventReceived
suspend fun postDiffs(diffs: List<TimelineDiff>) {
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<MatrixTimelineItem>.() -> 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<TimelineDiff>): DiffingResult {
val timelineItems = if (timelineItems.replayCache.isNotEmpty()) {
timelineItems.first()
} else {
emptyList()
}
val result = DiffingResult(timelineItems)
diffs.forEach { diff ->
result.applyDiff(diff)
}
return result
}
private fun MutableList<MatrixTimelineItem>.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 -> {
@ -80,25 +83,91 @@ 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.asMatrixTimelineItem(): MatrixTimelineItem {
return timelineItemFactory.map(this)
private fun TimelineItem.map(): MatrixTimelineItem {
return timelineItemMapper.map(this)
}
}
private class DiffingResult(initialItems: List<MatrixTimelineItem>) {
private val items = initialItems.toMutableList()
var hasNewEventsFromSync: Boolean = false
private set
var hasMembershipChangeEventFromSync: Boolean = false
private set
fun items(): List<MatrixTimelineItem> = items
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 skipProcessing(): Boolean {
return hasNewEventsFromSync && hasMembershipChangeEventFromSync
}
}

View file

@ -81,16 +81,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<List<MatrixTimelineItem>> =
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
private val _membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val _onSyncedEventReceived: MutableSharedFlow<Unit> = MutableSharedFlow(extraBufferCapacity = 1)
private val timelineEventContentMapper = TimelineEventContentMapper()
private val inReplyToMapper = InReplyToMapper(timelineEventContentMapper)
private val timelineItemMapper = MatrixTimelineItemMapper(
@ -99,18 +101,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)
@ -152,7 +155,13 @@ class RustTimeline(
.launchIn(this)
}
override val membershipChangeEventReceived: Flow<Unit> = timelineDiffProcessor.membershipChangeEventReceived
override val membershipChangeEventReceived: Flow<Unit> = _membershipChangeEventReceived
.onStart { timelineItemsSubscriber.subscribeIfNeeded() }
.onCompletion { timelineItemsSubscriber.unsubscribeIfNeeded() }
override val onSyncedEventReceived: Flow<Unit> = _onSyncedEventReceived
.onStart { timelineItemsSubscriber.subscribeIfNeeded() }
.onCompletion { timelineItemsSubscriber.unsubscribeIfNeeded() }
override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {

View file

@ -1,33 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 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
}

View file

@ -12,13 +12,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.
@ -29,7 +27,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()
@ -44,9 +41,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)

View file

@ -169,10 +169,12 @@ class MatrixTimelineDiffProcessorTest {
}
internal fun TestScope.createMatrixTimelineDiffProcessor(
timelineItems: MutableSharedFlow<List<MatrixTimelineItem>>,
): MatrixTimelineDiffProcessor {
timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = MutableSharedFlow(),
membershipChangeEventReceivedFlow: MutableSharedFlow<Unit> = MutableSharedFlow(),
syncedEventReceivedFlow: MutableSharedFlow<Unit> = MutableSharedFlow(),
): MatrixTimelineDiffProcessor {
val timelineEventContentMapper = TimelineEventContentMapper()
val timelineItemMapper = MatrixTimelineItemMapper(
val timelineItemFactory = MatrixTimelineItemMapper(
fetchDetailsForEvent = { _ -> Result.success(Unit) },
coroutineScope = this,
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
@ -182,6 +184,8 @@ internal fun TestScope.createMatrixTimelineDiffProcessor(
)
return MatrixTimelineDiffProcessor(
timelineItems = timelineItems,
timelineItemFactory = timelineItemMapper,
membershipChangeEventReceivedFlow = membershipChangeEventReceivedFlow,
syncedEventReceivedFlow = syncedEventReceivedFlow,
timelineItemMapper = timelineItemFactory,
)
}

View file

@ -99,7 +99,6 @@ private fun TestScope.createRustTimeline(
coroutineScope: CoroutineScope = backgroundScope,
dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io,
roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeFfiRoomListService()),
onNewSyncedEvent: () -> Unit = {},
): RustTimeline {
return RustTimeline(
inner = inner,
@ -109,6 +108,5 @@ private fun TestScope.createRustTimeline(
coroutineScope = coroutineScope,
dispatcher = dispatcher,
roomContentForwarder = roomContentForwarder,
onNewSyncedEvent = onNewSyncedEvent,
)
}

View file

@ -14,8 +14,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
@ -36,9 +34,12 @@ class TimelineItemsSubscriberTest {
val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> =
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()
@ -57,9 +58,12 @@ class TimelineItemsSubscriberTest {
val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> =
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()
@ -74,15 +78,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<List<MatrixTimelineItem>> =
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
val timeline = FakeFfiTimeline()
val onNewSyncedEventRecorder = lambdaRecorder<Unit> { }
val diffProcessor = createMatrixTimelineDiffProcessor(
timelineItems = timelineItems,
)
val timelineItemsSubscriber = createTimelineItemsSubscriber(
timeline = timeline,
timelineItems = timelineItems,
onNewSyncedEvent = onNewSyncedEventRecorder,
timelineDiffProcessor = diffProcessor,
)
timelineItems.test {
timelineItemsSubscriber.subscribeIfNeeded()
@ -101,7 +106,6 @@ class TimelineItemsSubscriberTest {
assertThat(final).isNotEmpty()
timelineItemsSubscriber.unsubscribeIfNeeded()
}
onNewSyncedEventRecorder.assertions().isCalledOnce()
}
@Ignore("JNA direct mapping has broken unit tests with FFI fakes")
@ -117,14 +121,12 @@ class TimelineItemsSubscriberTest {
private fun TestScope.createTimelineItemsSubscriber(
timeline: Timeline = FakeFfiTimeline(),
timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = 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,
)
}

View file

@ -48,6 +48,7 @@ class FakeTimeline(
)
),
override val membershipChangeEventReceived: Flow<Unit> = MutableSharedFlow(),
override val onSyncedEventReceived: Flow<Unit> = MutableSharedFlow(),
private val cancelSendResult: (TransactionId) -> Result<Unit> = { lambdaError() },
override val mode: Timeline.Mode = Timeline.Mode.Live,
private val markAsReadResult: (ReceiptType) -> Result<Unit> = { lambdaError() },

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e4063555b9c55be91104f51d5696383aba383d2b7b8d6ff490058f0228e45f81
size 30659
oid sha256:e8f2b0a758dd5aa20f5e54a1c2e5b094dae58cecbd51b14e6badee0de0d4f47c
size 29661

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:72ccd513cba258987e43054b269006625b76510efc26e4bc62ec2801f77be662
size 28093
oid sha256:3ca2cc0fb3cc31ee9d15b5c7d3c723b94fd4877bd25d419808d7c48f8e358496
size 26527

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:eb71efef332043ae5dd633a7bb877e3ef23123fb86550c0519d41ed05f737cf0
size 27284

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5
size 3853

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a7da5666d690d5d9a6be61225b169e4703626ebc15f2f8f4f3e98d2e322de39
size 28831
oid sha256:2708604aa1f4d6549b80ff388c45449c73577f39903810c438361fc068e440f9
size 27813

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:28f71f91a53008d73ed1f7cf209c7732b6fe0f4d7f65c2cf3997dff99f27c5e4
size 26402
oid sha256:7aa3113919aad2ea9cb7fd6b5b4aee6c9aa1ab600ee6cd6d18f75cc70469d1cb
size 25084

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2b314504a8f4aaf92828290706cd42ee5f88db1174b31d0652ea6bb8ddbf38b
size 25648

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c
size 3664