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 7f053e83e0
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.features.rolesandpermissions.impl.RoomMemberListDataSource
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter 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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.annotations.RoomCoroutineScope import io.element.android.libraries.di.annotations.RoomCoroutineScope
@ -193,11 +194,9 @@ class ChangeRolesPresenter(
selectedUsers: MutableState<ImmutableList<MatrixUser>>, selectedUsers: MutableState<ImmutableList<MatrixUser>>,
saveState: MutableState<AsyncAction<Boolean>>, saveState: MutableState<AsyncAction<Boolean>>,
) = launch { ) = launch {
saveState.value = AsyncAction.Loading runUpdatingState(saveState) {
val toAdd = selectedUsers.value - usersWithRole val toAdd = selectedUsers.value - usersWithRole
val toRemove = usersWithRole - selectedUsers.value val toRemove = usersWithRole - selectedUsers.value
val changes: List<UserRoleChange> = buildList { val changes: List<UserRoleChange> = buildList {
for (selectedUser in toAdd) { for (selectedUser in toAdd) {
analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, role.toAnalyticsMemberRole())) analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, role.toAnalyticsMemberRole()))
@ -208,16 +207,7 @@ class ChangeRolesPresenter(
add(UserRoleChange(selectedUser.userId, RoomMember.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)
}
} }
} }
@ -227,3 +217,4 @@ internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when
RoomMember.Role.Moderator -> RoomModeration.Role.Moderator RoomMember.Role.Moderator -> RoomModeration.Role.Moderator
RoomMember.Role.User -> RoomModeration.Role.User 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.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject 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.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData 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.persistentMapOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -56,9 +52,10 @@ class RoomMemberListPresenter(
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>, private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
) : Presenter<RoomMemberListState> { ) : Presenter<RoomMemberListState> {
var roomMembers: AsyncData<RoomMembers> by mutableStateOf(AsyncData.Loading())
@Composable @Composable
override fun present(): RoomMemberListState { override fun present(): RoomMemberListState {
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember { var searchResults by remember {
mutableStateOf<SearchBarResultState<AsyncData<RoomMembers>>>(SearchBarResultState.Initial()) mutableStateOf<SearchBarResultState<AsyncData<RoomMembers>>>(SearchBarResultState.Initial())
@ -78,14 +75,10 @@ class RoomMemberListPresenter(
.launchIn(this) .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) { LaunchedEffect(Unit) {
room.roomInfoFlow.map { it.activeMembersCount }
.distinctUntilChanged()
.collectLatest {
room.updateMembers() room.updateMembers()
} }
}
LaunchedEffect(membersState, roomMemberIdentityStates) { LaunchedEffect(membersState, roomMemberIdentityStates) {
if (membersState is RoomMembersState.Unknown) { if (membersState is RoomMembersState.Unknown) {
@ -165,13 +158,9 @@ class RoomMemberListPresenter(
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected -> 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()))
} }
} }
}
return RoomMemberListState( return RoomMemberListState(
roomMembers = roomMembers, roomMembers = roomMembers,

View file

@ -13,6 +13,6 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
sealed interface InternalRoomMemberModerationEvents : RoomMemberModerationEvents { sealed interface InternalRoomMemberModerationEvents : RoomMemberModerationEvents {
data class DoKickUser(val reason: String) : InternalRoomMemberModerationEvents data class DoKickUser(val reason: String) : InternalRoomMemberModerationEvents
data class DoBanUser(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 data object Reset : InternalRoomMemberModerationEvents
} }

View file

@ -65,6 +65,14 @@ class InternalRoomMemberModerationStateProvider : PreviewParameterProvider<Inter
selectedUser = anAlice(), selectedUser = anAlice(),
banUserAsyncAction = AsyncAction.Loading, 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 -> { is InternalRoomMemberModerationEvents.DoUnbanUser -> {
selectedUser?.let { selectedUser?.let {
coroutineScope.unbanUser(it.userId, unbanUserAsyncAction) coroutineScope.unbanUser(it.userId, event.reason, unbanUserAsyncAction)
} }
selectedUser = null selectedUser = null
} }
@ -198,10 +198,14 @@ class RoomMemberModerationPresenter(
private fun CoroutineScope.unbanUser( private fun CoroutineScope.unbanUser(
userId: UserId, userId: UserId,
reason: String,
unbanUserAction: MutableState<AsyncAction<Unit>>, unbanUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(unbanUserAction) { ) = runActionAndWaitForMembershipChange(unbanUserAction) {
analyticsService.capture(RoomModeration(RoomModeration.Action.UnbanMember)) analyticsService.capture(RoomModeration(RoomModeration.Action.UnbanMember))
room.unbanUser(userId = userId) room.unbanUser(
userId = userId,
reason = reason.takeIf { it.isNotBlank() },
)
} }
private fun <T> CoroutineScope.runActionAndWaitForMembershipChange( 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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.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.dialogs.TextFieldDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -93,12 +92,13 @@ private fun RoomMemberAsyncActions(
TextFieldDialog( TextFieldDialog(
title = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_title), 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), submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_action),
destructiveSubmit = true,
minLines = 2,
onSubmit = { reason -> onSubmit = { reason ->
state.eventSink(InternalRoomMemberModerationEvents.DoKickUser(reason = reason)) state.eventSink(InternalRoomMemberModerationEvents.DoKickUser(reason = reason))
}, },
onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) },
placeholder = stringResource(id = CommonStrings.common_reason), 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), content = stringResource(R.string.screen_bottom_sheet_manage_room_member_kick_member_confirmation_description),
value = "", value = "",
) )
@ -132,12 +132,13 @@ private fun RoomMemberAsyncActions(
TextFieldDialog( TextFieldDialog(
title = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_title), 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), submitText = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_action),
destructiveSubmit = true,
minLines = 2,
onSubmit = { reason -> onSubmit = { reason ->
state.eventSink(InternalRoomMemberModerationEvents.DoBanUser(reason = reason)) state.eventSink(InternalRoomMemberModerationEvents.DoBanUser(reason = reason))
}, },
onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) }, onDismissRequest = { state.eventSink(InternalRoomMemberModerationEvents.Reset) },
placeholder = stringResource(id = CommonStrings.common_reason), 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), content = stringResource(R.string.screen_bottom_sheet_manage_room_member_ban_member_confirmation_description),
value = "", value = "",
) )
@ -167,18 +168,22 @@ private fun RoomMemberAsyncActions(
} }
when (val action = state.unbanUserAsyncAction) { when (val action = state.unbanUserAsyncAction) {
is AsyncAction.Confirming -> { is AsyncAction.Confirming -> {
ConfirmationDialog( TextFieldDialog(
title = stringResource(R.string.screen_bottom_sheet_manage_room_member_unban_member_confirmation_title), 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), 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() val userDisplayName = selectedUser?.getBestName().orEmpty()
asyncIndicatorState.enqueue { asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_bottom_sheet_manage_room_member_unbanning_user, userDisplayName)) 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 -> { is AsyncAction.Failure -> {

View file

@ -291,7 +291,7 @@ class RoomMemberModerationPresenterTest {
) )
) )
skipItems(2) skipItems(2)
initialState.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser) initialState.eventSink(InternalRoomMemberModerationEvents.DoUnbanUser("Reason"))
skipItems(1) skipItems(1)
val loadingState = awaitState() val loadingState = awaitState()
assertThat(loadingState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(loadingState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)

View file

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

View file

@ -41,6 +41,7 @@ fun ListDialog(
submitText: String = stringResource(CommonStrings.action_ok), submitText: String = stringResource(CommonStrings.action_ok),
enabled: Boolean = true, enabled: Boolean = true,
applyPaddingToContents: Boolean = true, applyPaddingToContents: Boolean = true,
destructiveSubmit: Boolean = false,
listItems: LazyListScope.() -> Unit, listItems: LazyListScope.() -> Unit,
) { ) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let { val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@ -65,6 +66,7 @@ fun ListDialog(
enabled = enabled, enabled = enabled,
listItems = listItems, listItems = listItems,
applyPaddingToContents = applyPaddingToContents, applyPaddingToContents = applyPaddingToContents,
destructiveSubmit = destructiveSubmit,
) )
} }
} }
@ -79,6 +81,7 @@ private fun ListDialogContent(
title: String?, title: String?,
enabled: Boolean, enabled: Boolean,
applyPaddingToContents: Boolean, applyPaddingToContents: Boolean,
destructiveSubmit: Boolean,
subtitle: @Composable (() -> Unit)? = null, subtitle: @Composable (() -> Unit)? = null,
) { ) {
SimpleAlertDialogContent( SimpleAlertDialogContent(
@ -90,6 +93,7 @@ private fun ListDialogContent(
onSubmitClick = onSubmitClick, onSubmitClick = onSubmitClick,
enabled = enabled, enabled = enabled,
applyPaddingToContents = applyPaddingToContents, applyPaddingToContents = applyPaddingToContents,
destructiveSubmit = destructiveSubmit,
) { ) {
// No start padding if padding is already applied to the content // No start padding if padding is already applied to the content
val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp
@ -120,6 +124,7 @@ internal fun ListDialogContentPreview() {
cancelText = "Cancel", cancelText = "Cancel",
submitText = "Save", submitText = "Save",
enabled = true, enabled = true,
destructiveSubmit = false,
applyPaddingToContents = true, applyPaddingToContents = true,
) )
} }

View file

@ -43,11 +43,13 @@ fun TextFieldDialog(
validation: (String?) -> Boolean = { true }, validation: (String?) -> Boolean = { true },
onValidationErrorMessage: String? = null, onValidationErrorMessage: String? = null,
autoSelectOnDisplay: Boolean = true, autoSelectOnDisplay: Boolean = true,
maxLines: Int = 1, minLines: Int = 1,
maxLines: Int = minLines,
content: String? = null, content: String? = null,
label: String? = null, label: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
submitText: String = stringResource(CommonStrings.action_ok), submitText: String = stringResource(CommonStrings.action_ok),
destructiveSubmit: Boolean = false,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) { var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
@ -67,6 +69,7 @@ fun TextFieldDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
enabled = canSubmit, enabled = canSubmit,
submitText = submitText, submitText = submitText,
destructiveSubmit = destructiveSubmit,
modifier = modifier, modifier = modifier,
) { ) {
if (content != null) { if (content != null) {
@ -93,6 +96,7 @@ fun TextFieldDialog(
onSubmit(textFieldContents.text) onSubmit(textFieldContents.text)
} }
}), }),
minLines = minLines,
maxLines = maxLines, maxLines = maxLines,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -26,7 +26,8 @@ fun TextFieldListItem(
onTextChange: (String) -> Unit, onTextChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
error: String? = null, error: String? = null,
maxLines: Int = 1, minLines: Int = 1,
maxLines: Int = minLines,
label: String? = null, label: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
@ -53,7 +54,8 @@ fun TextFieldListItem(
onTextChange: (TextFieldValue) -> Unit, onTextChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
error: String? = null, error: String? = null,
maxLines: Int = 1, minLines: Int = 1,
maxLines: Int = minLines,
label: String? = null, label: String? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
@ -68,6 +70,7 @@ fun TextFieldListItem(
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
maxLines = maxLines, maxLines = maxLines,
minLines = minLines,
singleLine = maxLines == 1, singleLine = maxLines == 1,
modifier = modifier, modifier = modifier,
) )

View file

@ -55,6 +55,7 @@ interface Timeline : AutoCloseable {
val mode: Mode val mode: Mode
val membershipChangeEventReceived: Flow<Unit> val membershipChangeEventReceived: Flow<Unit>
val onSyncedEventReceived: Flow<Unit>
suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit>
suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> suspend fun markAsRead(receiptType: ReceiptType): Result<Unit>
suspend fun paginate(direction: PaginationDirection): Result<Boolean> 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 io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
@ -92,8 +95,6 @@ class JoinedRustRoom(
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32) private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
private val innerRoom = baseRoom.innerRoom private val innerRoom = baseRoom.innerRoom
override val syncUpdateFlow = MutableStateFlow(0L)
override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow { override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow {
val initial = emptyList<UserId>() val initial = emptyList<UserId>()
channel.trySend(initial) channel.trySend(initial)
@ -136,11 +137,24 @@ class JoinedRustRoom(
override val roomNotificationSettingsStateFlow = MutableStateFlow<RoomNotificationSettingsState>(RoomNotificationSettingsState.Unknown) override val roomNotificationSettingsStateFlow = MutableStateFlow<RoomNotificationSettingsState>(RoomNotificationSettingsState.Unknown)
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) { override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
syncUpdateFlow.value = systemClock.epochMillis()
override val syncUpdateFlow = flow {
var counter = 0L
liveTimeline.onSyncedEventReceived.collect {
emit(++counter)
} }
}.stateIn(
scope = roomCoroutineScope,
started = WhileSubscribed(),
initialValue = 0L,
)
init { init {
subscribeToRoomMembersChange()
}
private fun subscribeToRoomMembersChange() {
val powerLevelChanges = roomInfoFlow.map { it.roomPowerLevels }.distinctUntilChanged() val powerLevelChanges = roomInfoFlow.map { it.roomPowerLevels }.distinctUntilChanged()
val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) } val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) }
combine(membershipChanges, powerLevelChanges) { _, _ -> } combine(membershipChanges, powerLevelChanges) { _, _ -> }
@ -479,7 +493,6 @@ class JoinedRustRoom(
private fun InnerTimeline.map( private fun InnerTimeline.map(
mode: Timeline.Mode, mode: Timeline.Mode,
onNewSyncedEvent: () -> Unit = {},
): Timeline { ): Timeline {
val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$this") val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$this")
return RustTimeline( return RustTimeline(
@ -490,7 +503,6 @@ class JoinedRustRoom(
coroutineScope = timelineCoroutineScope, coroutineScope = timelineCoroutineScope,
dispatcher = roomDispatcher, dispatcher = roomDispatcher,
roomContentForwarder = roomContentForwarder, roomContentForwarder = roomContentForwarder,
onNewSyncedEvent = onNewSyncedEvent,
) )
} }
} }

View file

@ -8,9 +8,10 @@
package io.element.android.libraries.matrix.impl.timeline 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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent 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.MutableSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -21,58 +22,60 @@ import timber.log.Timber
internal class MatrixTimelineDiffProcessor( internal class MatrixTimelineDiffProcessor(
private val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>>, 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 mutex = Mutex()
private val _membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val membershipChangeEventReceived: Flow<Unit> = _membershipChangeEventReceived
suspend fun postDiffs(diffs: List<TimelineDiff>) { suspend fun postDiffs(diffs: List<TimelineDiff>) {
updateTimelineItems {
Timber.v("Update timeline items from postDiffs (with ${diffs.size} items) on ${Thread.currentThread()}")
diffs.forEach { diff ->
applyDiff(diff)
}
}
}
private suspend fun updateTimelineItems(block: MutableList<MatrixTimelineItem>.() -> Unit) =
mutex.withLock { mutex.withLock {
val mutableTimelineItems = if (timelineItems.replayCache.isNotEmpty()) { Timber.v("Update timeline items from postDiffs (with ${diffs.size} items) on ${Thread.currentThread()}")
timelineItems.first().toMutableList() val result = processDiffs(diffs)
} else { timelineItems.emit(result.items())
mutableListOf() if (result.hasNewEventsFromSync) {
syncedEventReceivedFlow.emit(Unit)
}
if (result.hasMembershipChangeEventFromSync) {
membershipChangeEventReceivedFlow.emit(Unit)
}
} }
block(mutableTimelineItems)
timelineItems.tryEmit(mutableTimelineItems)
} }
private fun MutableList<MatrixTimelineItem>.applyDiff(diff: TimelineDiff) { 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 DiffingResult.applyDiff(diff: TimelineDiff) {
when (diff) { when (diff) {
is TimelineDiff.Append -> { is TimelineDiff.Append -> {
val items = diff.values.map { it.asMatrixTimelineItem() } diff.values.fastForEach { item ->
addAll(items) add(item.map())
}
} }
is TimelineDiff.PushBack -> { is TimelineDiff.PushBack -> {
val item = diff.value.asMatrixTimelineItem() val item = diff.value.map()
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)
}
add(item) add(item)
} }
is TimelineDiff.PushFront -> { is TimelineDiff.PushFront -> {
val item = diff.value.asMatrixTimelineItem() val item = diff.value.map()
add(0, item) add(0, item)
} }
is TimelineDiff.Set -> { is TimelineDiff.Set -> {
val item = diff.value.asMatrixTimelineItem() val item = diff.value.map()
set(diff.index.toInt(), item) set(diff.index.toInt(), item)
} }
is TimelineDiff.Insert -> { is TimelineDiff.Insert -> {
val item = diff.value.asMatrixTimelineItem() val item = diff.value.map()
add(diff.index.toInt(), item) add(diff.index.toInt(), item)
} }
is TimelineDiff.Remove -> { is TimelineDiff.Remove -> {
@ -80,25 +83,91 @@ internal class MatrixTimelineDiffProcessor(
} }
is TimelineDiff.Reset -> { is TimelineDiff.Reset -> {
clear() clear()
val items = diff.values.map { it.asMatrixTimelineItem() } diff.values.fastForEach { item ->
addAll(items) add(item.map())
}
} }
TimelineDiff.PopFront -> { TimelineDiff.PopFront -> {
removeFirstOrNull() removeFirst()
} }
TimelineDiff.PopBack -> { TimelineDiff.PopBack -> {
removeLastOrNull() removeLast()
} }
TimelineDiff.Clear -> { TimelineDiff.Clear -> {
clear() clear()
} }
is TimelineDiff.Truncate -> { is TimelineDiff.Truncate -> {
subList(diff.length.toInt(), size).clear() truncate(diff.length.toInt())
} }
} }
} }
private fun TimelineItem.asMatrixTimelineItem(): MatrixTimelineItem { private fun TimelineItem.map(): MatrixTimelineItem {
return timelineItemFactory.map(this) 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( class RustTimeline(
private val inner: InnerTimeline, private val inner: InnerTimeline,
override val mode: Timeline.Mode, override val mode: Timeline.Mode,
systemClock: SystemClock, private val systemClock: SystemClock,
private val joinedRoom: JoinedRoom, private val joinedRoom: JoinedRoom,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val dispatcher: CoroutineDispatcher, private val dispatcher: CoroutineDispatcher,
private val roomContentForwarder: RoomContentForwarder, private val roomContentForwarder: RoomContentForwarder,
onNewSyncedEvent: () -> Unit,
) : Timeline { ) : Timeline {
private val _timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = private val _timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> =
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) 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 timelineEventContentMapper = TimelineEventContentMapper()
private val inReplyToMapper = InReplyToMapper(timelineEventContentMapper) private val inReplyToMapper = InReplyToMapper(timelineEventContentMapper)
private val timelineItemMapper = MatrixTimelineItemMapper( private val timelineItemMapper = MatrixTimelineItemMapper(
@ -99,18 +101,19 @@ class RustTimeline(
virtualTimelineItemMapper = VirtualTimelineItemMapper(), virtualTimelineItemMapper = VirtualTimelineItemMapper(),
eventTimelineItemMapper = EventTimelineItemMapper( eventTimelineItemMapper = EventTimelineItemMapper(
contentMapper = timelineEventContentMapper contentMapper = timelineEventContentMapper
) ),
) )
private val timelineDiffProcessor = MatrixTimelineDiffProcessor( private val timelineDiffProcessor = MatrixTimelineDiffProcessor(
timelineItems = _timelineItems, timelineItems = _timelineItems,
timelineItemFactory = timelineItemMapper, membershipChangeEventReceivedFlow = _membershipChangeEventReceived,
syncedEventReceivedFlow = _onSyncedEventReceived,
timelineItemMapper = timelineItemMapper,
) )
private val timelineItemsSubscriber = TimelineItemsSubscriber( private val timelineItemsSubscriber = TimelineItemsSubscriber(
timeline = inner, timeline = inner,
timelineCoroutineScope = coroutineScope, timelineCoroutineScope = coroutineScope,
timelineDiffProcessor = timelineDiffProcessor, timelineDiffProcessor = timelineDiffProcessor,
dispatcher = dispatcher, dispatcher = dispatcher,
onNewSyncedEvent = onNewSyncedEvent,
) )
private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode) private val roomBeginningPostProcessor = RoomBeginningPostProcessor(mode)
@ -152,7 +155,13 @@ class RustTimeline(
.launchIn(this) .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) { override suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions { 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.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.Timeline 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. * 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, dispatcher: CoroutineDispatcher,
private val timeline: Timeline, private val timeline: Timeline,
private val timelineDiffProcessor: MatrixTimelineDiffProcessor, private val timelineDiffProcessor: MatrixTimelineDiffProcessor,
private val onNewSyncedEvent: () -> Unit,
) { ) {
private var subscriptionCount = 0 private var subscriptionCount = 0
private val mutex = Mutex() private val mutex = Mutex()
@ -44,9 +41,6 @@ internal class TimelineItemsSubscriber(
if (subscriptionCount == 0) { if (subscriptionCount == 0) {
timeline.timelineDiffFlow() timeline.timelineDiffFlow()
.onEach { diffs -> .onEach { diffs ->
if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) {
onNewSyncedEvent()
}
timelineDiffProcessor.postDiffs(diffs) timelineDiffProcessor.postDiffs(diffs)
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)

View file

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

View file

@ -99,7 +99,6 @@ private fun TestScope.createRustTimeline(
coroutineScope: CoroutineScope = backgroundScope, coroutineScope: CoroutineScope = backgroundScope,
dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io, dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io,
roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeFfiRoomListService()), roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeFfiRoomListService()),
onNewSyncedEvent: () -> Unit = {},
): RustTimeline { ): RustTimeline {
return RustTimeline( return RustTimeline(
inner = inner, inner = inner,
@ -109,6 +108,5 @@ private fun TestScope.createRustTimeline(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
dispatcher = dispatcher, dispatcher = dispatcher,
roomContentForwarder = roomContentForwarder, 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.factories.aRustEventTimelineItem
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimeline import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimeline
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineItem 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@ -36,9 +34,12 @@ class TimelineItemsSubscriberTest {
val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> =
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
val timeline = FakeFfiTimeline() val timeline = FakeFfiTimeline()
val diffProcessor = createMatrixTimelineDiffProcessor(
timelineItems = timelineItems,
)
val timelineItemsSubscriber = createTimelineItemsSubscriber( val timelineItemsSubscriber = createTimelineItemsSubscriber(
timeline = timeline, timeline = timeline,
timelineItems = timelineItems, timelineDiffProcessor = diffProcessor,
) )
timelineItems.test { timelineItems.test {
timelineItemsSubscriber.subscribeIfNeeded() timelineItemsSubscriber.subscribeIfNeeded()
@ -57,9 +58,12 @@ class TimelineItemsSubscriberTest {
val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> =
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
val timeline = FakeFfiTimeline() val timeline = FakeFfiTimeline()
val diffProcessor = createMatrixTimelineDiffProcessor(
timelineItems = timelineItems,
)
val timelineItemsSubscriber = createTimelineItemsSubscriber( val timelineItemsSubscriber = createTimelineItemsSubscriber(
timeline = timeline, timeline = timeline,
timelineItems = timelineItems, timelineDiffProcessor = diffProcessor,
) )
timelineItems.test { timelineItems.test {
timelineItemsSubscriber.subscribeIfNeeded() timelineItemsSubscriber.subscribeIfNeeded()
@ -74,15 +78,16 @@ class TimelineItemsSubscriberTest {
@Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Ignore("JNA direct mapping has broken unit tests with FFI fakes")
@Test @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>> = val timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> =
MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE) MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE)
val timeline = FakeFfiTimeline() val timeline = FakeFfiTimeline()
val onNewSyncedEventRecorder = lambdaRecorder<Unit> { } val diffProcessor = createMatrixTimelineDiffProcessor(
timelineItems = timelineItems,
)
val timelineItemsSubscriber = createTimelineItemsSubscriber( val timelineItemsSubscriber = createTimelineItemsSubscriber(
timeline = timeline, timeline = timeline,
timelineItems = timelineItems, timelineDiffProcessor = diffProcessor,
onNewSyncedEvent = onNewSyncedEventRecorder,
) )
timelineItems.test { timelineItems.test {
timelineItemsSubscriber.subscribeIfNeeded() timelineItemsSubscriber.subscribeIfNeeded()
@ -101,7 +106,6 @@ class TimelineItemsSubscriberTest {
assertThat(final).isNotEmpty() assertThat(final).isNotEmpty()
timelineItemsSubscriber.unsubscribeIfNeeded() timelineItemsSubscriber.unsubscribeIfNeeded()
} }
onNewSyncedEventRecorder.assertions().isCalledOnce()
} }
@Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Ignore("JNA direct mapping has broken unit tests with FFI fakes")
@ -117,14 +121,12 @@ class TimelineItemsSubscriberTest {
private fun TestScope.createTimelineItemsSubscriber( private fun TestScope.createTimelineItemsSubscriber(
timeline: Timeline = FakeFfiTimeline(), timeline: Timeline = FakeFfiTimeline(),
timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = MutableSharedFlow(replay = 1, extraBufferCapacity = Int.MAX_VALUE), timelineDiffProcessor: MatrixTimelineDiffProcessor = createMatrixTimelineDiffProcessor(),
onNewSyncedEvent: () -> Unit = { lambdaError() },
): TimelineItemsSubscriber { ): TimelineItemsSubscriber {
return TimelineItemsSubscriber( return TimelineItemsSubscriber(
timelineCoroutineScope = backgroundScope, timelineCoroutineScope = backgroundScope,
dispatcher = StandardTestDispatcher(testScheduler), dispatcher = StandardTestDispatcher(testScheduler),
timeline = timeline, timeline = timeline,
timelineDiffProcessor = createMatrixTimelineDiffProcessor(timelineItems), timelineDiffProcessor = timelineDiffProcessor,
onNewSyncedEvent = onNewSyncedEvent,
) )
} }

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:72ccd513cba258987e43054b269006625b76510efc26e4bc62ec2801f77be662 oid sha256:3ca2cc0fb3cc31ee9d15b5c7d3c723b94fd4877bd25d419808d7c48f8e358496
size 28093 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 version https://git-lfs.github.com/spec/v1
oid sha256:3a7da5666d690d5d9a6be61225b169e4703626ebc15f2f8f4f3e98d2e322de39 oid sha256:2708604aa1f4d6549b80ff388c45449c73577f39903810c438361fc068e440f9
size 28831 size 27813

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:28f71f91a53008d73ed1f7cf209c7732b6fe0f4d7f65c2cf3997dff99f27c5e4 oid sha256:7aa3113919aad2ea9cb7fd6b5b4aee6c9aa1ab600ee6cd6d18f75cc70469d1cb
size 26402 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