Merge pull request #5728 from element-hq/feature/fga/members_improvements
Changes : member list improvements
This commit is contained in:
commit
7f053e83e0
29 changed files with 254 additions and 176 deletions
|
|
@ -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,22 +207,14 @@ class ChangeRolesPresenter(
|
||||||
add(UserRoleChange(selectedUser.userId, RoomMember.Role.User))
|
add(UserRoleChange(selectedUser.userId, RoomMember.Role.User))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
room.updateUsersRoles(changes).map { true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
room.updateUsersRoles(changes)
|
internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) {
|
||||||
.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
|
is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin
|
||||||
RoomMember.Role.Admin -> RoomModeration.Role.Administrator
|
RoomMember.Role.Admin -> RoomModeration.Role.Administrator
|
||||||
RoomMember.Role.Moderator -> RoomModeration.Role.Moderator
|
RoomMember.Role.Moderator -> RoomModeration.Role.Moderator
|
||||||
RoomMember.Role.User -> RoomModeration.Role.User
|
RoomMember.Role.User -> RoomModeration.Role.User
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 -> {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -169,10 +169,12 @@ class MatrixTimelineDiffProcessorTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun TestScope.createMatrixTimelineDiffProcessor(
|
internal fun TestScope.createMatrixTimelineDiffProcessor(
|
||||||
timelineItems: MutableSharedFlow<List<MatrixTimelineItem>>,
|
timelineItems: MutableSharedFlow<List<MatrixTimelineItem>> = MutableSharedFlow(),
|
||||||
): MatrixTimelineDiffProcessor {
|
membershipChangeEventReceivedFlow: MutableSharedFlow<Unit> = MutableSharedFlow(),
|
||||||
|
syncedEventReceivedFlow: MutableSharedFlow<Unit> = MutableSharedFlow(),
|
||||||
|
): 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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() },
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:eb71efef332043ae5dd633a7bb877e3ef23123fb86550c0519d41ed05f737cf0
|
||||||
|
size 27284
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:21aee17ee9a292bb2f0d3d2ec2db565ebb8d5df753454a5a002f033ed5ed39c5
|
||||||
|
size 3853
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c2b314504a8f4aaf92828290706cd42ee5f88db1174b31d0652ea6bb8ddbf38b
|
||||||
|
size 25648
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:520cae2544153ba45f8fe4b021286c2d22da06aa1b60be7f1b13879723ff994c
|
||||||
|
size 3664
|
||||||
Loading…
Add table
Add a link
Reference in a new issue