Merge pull request #5806 from element-hq/feature/fga/iterate_members
Change : improve room and space member list
This commit is contained in:
commit
78d5850fe6
51 changed files with 668 additions and 662 deletions
|
|
@ -1,39 +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.features.roomdetails.impl.members
|
|
||||||
|
|
||||||
import dev.zacsweers.metro.Inject
|
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
class RoomMemberListDataSource(
|
|
||||||
private val room: BaseRoom,
|
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
|
||||||
) {
|
|
||||||
suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) {
|
|
||||||
val roomMembersState = room.membersStateFlow.value
|
|
||||||
val activeRoomMembers = roomMembersState.roomMembers()
|
|
||||||
?.filter { it.membership.isActive() }
|
|
||||||
.orEmpty()
|
|
||||||
val filteredMembers = if (query.isBlank()) {
|
|
||||||
activeRoomMembers
|
|
||||||
} else {
|
|
||||||
activeRoomMembers.filter { member ->
|
|
||||||
member.userId.value.contains(query, ignoreCase = true) ||
|
|
||||||
member.displayName?.contains(query, ignoreCase = true).orFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filteredMembers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,7 @@ package io.element.android.features.roomdetails.impl.members
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
|
||||||
sealed interface RoomMemberListEvents {
|
sealed interface RoomMemberListEvents {
|
||||||
|
data class ChangeSelectedSection(val section: SelectedSection) : RoomMemberListEvents
|
||||||
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
|
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
|
||||||
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
|
|
||||||
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
|
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.lifecycle.subscribe
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
|
@ -21,6 +23,7 @@ import io.element.android.annotations.ContributesNode
|
||||||
import io.element.android.features.roommembermoderation.api.ModerationAction
|
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.RoomMemberModerationRenderer
|
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
|
||||||
|
import io.element.android.libraries.architecture.appyx.launchMolecule
|
||||||
import io.element.android.libraries.architecture.callback
|
import io.element.android.libraries.architecture.callback
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
|
|
@ -41,6 +44,7 @@ class RoomMemberListNode(
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
|
private val stateFlow = launchMolecule { presenter.present() }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
|
|
@ -64,7 +68,7 @@ class RoomMemberListNode(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state by stateFlow.collectAsState()
|
||||||
RoomMemberListView(
|
RoomMemberListView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -18,12 +19,12 @@ 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.RoomMemberModerationEvents
|
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents.ShowActionsForUser
|
||||||
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
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.map
|
||||||
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.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
|
|
@ -40,7 +41,6 @@ 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.first
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -48,22 +48,15 @@ import kotlinx.coroutines.withContext
|
||||||
@Inject
|
@Inject
|
||||||
class RoomMemberListPresenter(
|
class RoomMemberListPresenter(
|
||||||
private val room: JoinedRoom,
|
private val room: JoinedRoom,
|
||||||
private val roomMemberListDataSource: RoomMemberListDataSource,
|
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
|
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
) : Presenter<RoomMemberListState> {
|
) : Presenter<RoomMemberListState> {
|
||||||
private var roomMembers: AsyncData<RoomMembers> by mutableStateOf(AsyncData.Loading())
|
|
||||||
private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator()
|
private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): RoomMemberListState {
|
override fun present(): RoomMemberListState {
|
||||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
var searchResults by remember {
|
|
||||||
mutableStateOf<SearchBarResultState<AsyncData<RoomMembers>>>(SearchBarResultState.Initial())
|
|
||||||
}
|
|
||||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val membersState by room.membersStateFlow.collectAsState()
|
val membersState by room.membersStateFlow.collectAsState()
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
|
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
|
||||||
|
|
@ -77,6 +70,10 @@ class RoomMemberListPresenter(
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) }
|
||||||
|
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
||||||
|
var filteredRoomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
|
||||||
|
|
||||||
// Update the room members when the screen is loaded
|
// Update the room members when the screen is loaded
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
room.updateMembers()
|
room.updateMembers()
|
||||||
|
|
@ -94,7 +91,7 @@ class RoomMemberListPresenter(
|
||||||
}
|
}
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
|
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
|
||||||
val info = room.roomInfoFlow.first()
|
val info = room.info()
|
||||||
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) {
|
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) {
|
||||||
// Don't display initial room member list if we have less than half of the joined members:
|
// Don't display initial room member list if we have less than half of the joined members:
|
||||||
// This result will come from the timeline loading membership events and it'll be wrong.
|
// This result will come from the timeline loading membership events and it'll be wrong.
|
||||||
|
|
@ -121,58 +118,38 @@ class RoomMemberListPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(membersState, searchQuery, isSearchActive) {
|
LaunchedEffect(searchQuery, roomMembers) {
|
||||||
|
filteredRoomMembers = roomMembers.map { members ->
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
searchResults = if (searchQuery.isEmpty() || !isSearchActive) {
|
members.filter(searchQuery)
|
||||||
SearchBarResultState.Initial()
|
|
||||||
} else {
|
|
||||||
val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership }
|
|
||||||
if (results.isEmpty()) {
|
|
||||||
SearchBarResultState.NoResultsFound()
|
|
||||||
} else {
|
|
||||||
val result = RoomMembers(
|
|
||||||
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList())
|
|
||||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
|
||||||
.toImmutableList(),
|
|
||||||
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
|
|
||||||
.sortedWith(powerLevelRoomMemberComparator)
|
|
||||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
|
||||||
.toImmutableList(),
|
|
||||||
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList())
|
|
||||||
.sortedBy { it.userId.value }
|
|
||||||
.map { it.withIdentityState(roomMemberIdentityStates) }
|
|
||||||
.toImmutableList(),
|
|
||||||
)
|
|
||||||
SearchBarResultState.Results(
|
|
||||||
if (membersState is RoomMembersState.Pending) {
|
|
||||||
AsyncData.Loading(result)
|
|
||||||
} else {
|
|
||||||
AsyncData.Success(result)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: RoomMemberListEvents) {
|
fun handleEvent(event: RoomMemberListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
|
||||||
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
|
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||||
is RoomMemberListEvents.RoomMemberSelected ->
|
is RoomMemberListEvents.RoomMemberSelected ->
|
||||||
roomModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.roomMember.toMatrixUser()))
|
roomModerationState.eventSink(ShowActionsForUser(event.roomMember.toMatrixUser()))
|
||||||
|
is RoomMemberListEvents.ChangeSelectedSection -> selectedSection = event.section
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return RoomMemberListState(
|
val state = RoomMemberListState(
|
||||||
roomMembers = roomMembers,
|
roomMembers = roomMembers,
|
||||||
|
filteredRoomMembers = filteredRoomMembers,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
searchResults = searchResults,
|
|
||||||
isSearchActive = isSearchActive,
|
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
moderationState = roomModerationState,
|
moderationState = roomModerationState,
|
||||||
|
selectedSection = selectedSection,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
|
if (!state.showBannedSection && selectedSection == SelectedSection.BANNED) {
|
||||||
|
SideEffect {
|
||||||
|
selectedSection = SelectedSection.MEMBERS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
|
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
|
||||||
|
|
|
||||||
|
|
@ -10,26 +10,57 @@ package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
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
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
data class RoomMemberListState(
|
data class RoomMemberListState(
|
||||||
val roomMembers: AsyncData<RoomMembers>,
|
// Only used to know if we can show the banned section
|
||||||
|
private val roomMembers: AsyncData<RoomMembers>,
|
||||||
|
val filteredRoomMembers: AsyncData<RoomMembers>,
|
||||||
val searchQuery: String,
|
val searchQuery: String,
|
||||||
val searchResults: SearchBarResultState<AsyncData<RoomMembers>>,
|
|
||||||
val isSearchActive: Boolean,
|
|
||||||
val canInvite: Boolean,
|
val canInvite: Boolean,
|
||||||
|
val selectedSection: SelectedSection,
|
||||||
val moderationState: RoomMemberModerationState,
|
val moderationState: RoomMemberModerationState,
|
||||||
val eventSink: (RoomMemberListEvents) -> Unit,
|
val eventSink: (RoomMemberListEvents) -> Unit,
|
||||||
)
|
) {
|
||||||
|
val showBannedSection: Boolean = moderationState.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SelectedSection {
|
||||||
|
MEMBERS,
|
||||||
|
BANNED
|
||||||
|
}
|
||||||
|
|
||||||
data class RoomMembers(
|
data class RoomMembers(
|
||||||
val invited: ImmutableList<RoomMemberWithIdentityState>,
|
val invited: ImmutableList<RoomMemberWithIdentityState>,
|
||||||
val joined: ImmutableList<RoomMemberWithIdentityState>,
|
val joined: ImmutableList<RoomMemberWithIdentityState>,
|
||||||
val banned: ImmutableList<RoomMemberWithIdentityState>,
|
val banned: ImmutableList<RoomMemberWithIdentityState>,
|
||||||
|
) {
|
||||||
|
fun isEmpty(section: SelectedSection): Boolean {
|
||||||
|
return when (section) {
|
||||||
|
SelectedSection.MEMBERS -> invited.isEmpty() && joined.isEmpty()
|
||||||
|
SelectedSection.BANNED -> banned.isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filter(query: String): RoomMembers {
|
||||||
|
if (query.isBlank()) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
val filterPredicate = { member: RoomMemberWithIdentityState ->
|
||||||
|
member.roomMember.userId.value.contains(query, ignoreCase = true) ||
|
||||||
|
member.roomMember.displayName?.contains(query, ignoreCase = true).orFalse()
|
||||||
|
}
|
||||||
|
return RoomMembers(
|
||||||
|
invited = invited.filter(filterPredicate).toImmutableList(),
|
||||||
|
joined = joined.filter(filterPredicate).toImmutableList(),
|
||||||
|
banned = banned.filter(filterPredicate).toImmutableList(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class RoomMemberWithIdentityState(
|
data class RoomMemberWithIdentityState(
|
||||||
val roomMember: RoomMember,
|
val roomMember: RoomMember,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
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
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.architecture.map
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
|
@ -23,113 +23,75 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
||||||
override val values: Sequence<RoomMemberListState>
|
override val values: Sequence<RoomMemberListState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aRoomMemberListState(
|
aRoomMemberListState(
|
||||||
roomMembers = AsyncData.Success(
|
roomMembers = AsyncData.Loading(),
|
||||||
RoomMembers(
|
selectedSection = SelectedSection.MEMBERS,
|
||||||
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
|
|
||||||
joined = persistentListOf(anAlice().withIdentity(), aBob().withIdentity(), aWalter().withIdentity()),
|
|
||||||
banned = persistentListOf(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
aRoomMemberListState(
|
|
||||||
roomMembers = AsyncData.Success(
|
|
||||||
RoomMembers(
|
|
||||||
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
|
|
||||||
joined = persistentListOf(
|
|
||||||
anAlice().withIdentity(identityState = IdentityState.Verified),
|
|
||||||
aBob().withIdentity(identityState = IdentityState.PinViolation),
|
|
||||||
aWalter().withIdentity(identityState = IdentityState.VerificationViolation)
|
|
||||||
),
|
|
||||||
banned = persistentListOf(),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
moderationState = aRoomMemberModerationState(canBan = true)
|
|
||||||
),
|
|
||||||
aRoomMemberListState(roomMembers = AsyncData.Loading()),
|
|
||||||
aRoomMemberListState().copy(canInvite = true),
|
|
||||||
aRoomMemberListState().copy(isSearchActive = false),
|
|
||||||
aRoomMemberListState().copy(isSearchActive = true),
|
|
||||||
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
|
|
||||||
aRoomMemberListState().copy(
|
|
||||||
isSearchActive = true,
|
|
||||||
searchQuery = "@someone:matrix.org",
|
|
||||||
searchResults = SearchBarResultState.Results(
|
|
||||||
AsyncData.Success(
|
|
||||||
RoomMembers(
|
|
||||||
invited = persistentListOf(aVictor().withIdentity()),
|
|
||||||
joined = persistentListOf(anAlice().withIdentity()),
|
|
||||||
banned = persistentListOf(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
aRoomMemberListState().copy(
|
|
||||||
isSearchActive = true,
|
|
||||||
searchQuery = "something-with-no-results",
|
|
||||||
searchResults = SearchBarResultState.NoResultsFound()
|
|
||||||
),
|
),
|
||||||
aRoomMemberListState(
|
aRoomMemberListState(
|
||||||
roomMembers = AsyncData.Failure(Exception("Error details")),
|
roomMembers = AsyncData.Failure(Exception("Error details")),
|
||||||
|
selectedSection = SelectedSection.MEMBERS,
|
||||||
|
),
|
||||||
|
aRoomMemberListState(
|
||||||
|
roomMembers = aLoadedRoomMembers(),
|
||||||
|
selectedSection = SelectedSection.MEMBERS,
|
||||||
|
),
|
||||||
|
aRoomMemberListState(
|
||||||
|
roomMembers = aLoadedRoomMembers(),
|
||||||
|
selectedSection = SelectedSection.BANNED,
|
||||||
|
moderationState = aRoomMemberModerationState(canBan = true),
|
||||||
|
),
|
||||||
|
aRoomMemberListState(
|
||||||
|
roomMembers = aLoadedRoomMembers(),
|
||||||
|
canInvite = true,
|
||||||
|
selectedSection = SelectedSection.MEMBERS,
|
||||||
|
),
|
||||||
|
aRoomMemberListState(
|
||||||
|
roomMembers = aLoadedRoomMembers(),
|
||||||
|
searchQuery = "alice",
|
||||||
|
selectedSection = SelectedSection.MEMBERS,
|
||||||
|
),
|
||||||
|
aRoomMemberListState(
|
||||||
|
roomMembers = aLoadedRoomMembers(),
|
||||||
|
searchQuery = "something-with-no-results",
|
||||||
|
selectedSection = SelectedSection.MEMBERS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<RoomMemberListState> {
|
private fun aLoadedRoomMembers() = AsyncData.Success(
|
||||||
override val values: Sequence<RoomMemberListState>
|
|
||||||
get() = sequenceOf(
|
|
||||||
aRoomMemberListState(
|
|
||||||
roomMembers = AsyncData.Success(
|
|
||||||
RoomMembers(
|
RoomMembers(
|
||||||
invited = persistentListOf(),
|
invited = persistentListOf(
|
||||||
joined = persistentListOf(),
|
anInvitedVictor().withIdentity(),
|
||||||
|
anInvitedWalter().withIdentity(),
|
||||||
|
),
|
||||||
|
joined = persistentListOf(
|
||||||
|
anAlice().withIdentity(identityState = IdentityState.Verified),
|
||||||
|
aBob().withIdentity(identityState = IdentityState.PinViolation),
|
||||||
|
aCarol().withIdentity(),
|
||||||
|
aDavid().withIdentity(),
|
||||||
|
anEve().withIdentity(identityState = IdentityState.VerificationViolation)
|
||||||
|
),
|
||||||
banned = persistentListOf(
|
banned = persistentListOf(
|
||||||
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
|
aBannedMallory().withIdentity(),
|
||||||
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
|
aBannedSusie().withIdentity()
|
||||||
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
),
|
|
||||||
moderationState = aRoomMemberModerationState(),
|
|
||||||
),
|
|
||||||
aRoomMemberListState(
|
|
||||||
roomMembers = AsyncData.Loading(
|
|
||||||
RoomMembers(
|
|
||||||
invited = persistentListOf(),
|
|
||||||
joined = persistentListOf(),
|
|
||||||
banned = persistentListOf(
|
|
||||||
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
|
|
||||||
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
|
|
||||||
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
),
|
|
||||||
moderationState = aRoomMemberModerationState(),
|
|
||||||
),
|
|
||||||
aRoomMemberListState(
|
|
||||||
roomMembers = AsyncData.Success(
|
|
||||||
RoomMembers(
|
|
||||||
invited = persistentListOf(),
|
|
||||||
joined = persistentListOf(),
|
|
||||||
banned = persistentListOf(),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
moderationState = aRoomMemberModerationState(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun aRoomMemberListState(
|
internal fun aRoomMemberListState(
|
||||||
roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
|
roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
|
||||||
searchResults: SearchBarResultState<AsyncData<RoomMembers>> = SearchBarResultState.Initial(),
|
|
||||||
moderationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
moderationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
||||||
|
selectedSection: SelectedSection = SelectedSection.MEMBERS,
|
||||||
|
searchQuery: String = "",
|
||||||
|
canInvite: Boolean = false,
|
||||||
|
eventSink: (RoomMemberListEvents) -> Unit = {},
|
||||||
) = RoomMemberListState(
|
) = RoomMemberListState(
|
||||||
roomMembers = roomMembers,
|
roomMembers = roomMembers,
|
||||||
searchQuery = "",
|
filteredRoomMembers = roomMembers.map { it.filter(searchQuery) },
|
||||||
searchResults = searchResults,
|
searchQuery = searchQuery,
|
||||||
isSearchActive = false,
|
canInvite = canInvite,
|
||||||
canInvite = false,
|
|
||||||
moderationState = moderationState,
|
moderationState = moderationState,
|
||||||
eventSink = {}
|
selectedSection = selectedSection,
|
||||||
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aRoomMemberModerationState(
|
fun aRoomMemberModerationState(
|
||||||
|
|
@ -168,21 +130,30 @@ fun aRoomMember(
|
||||||
fun aRoomMemberList() = persistentListOf(
|
fun aRoomMemberList() = persistentListOf(
|
||||||
anAlice(),
|
anAlice(),
|
||||||
aBob(),
|
aBob(),
|
||||||
aRoomMember(UserId("@carol:server.org"), "Carol"),
|
aCarol(),
|
||||||
aRoomMember(UserId("@david:server.org"), "David"),
|
aDavid(),
|
||||||
aRoomMember(UserId("@eve:server.org"), "Eve"),
|
anEve(),
|
||||||
aRoomMember(UserId("@justin:server.org"), "Justin"),
|
anInvitedVictor(),
|
||||||
aRoomMember(UserId("@mallory:server.org"), "Mallory"),
|
anInvitedWalter(),
|
||||||
aRoomMember(UserId("@susie:server.org"), "Susie"),
|
aBannedSusie(),
|
||||||
aVictor(),
|
aBannedMallory(),
|
||||||
aWalter(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun anEve(): RoomMember = aRoomMember(UserId("@eve:server.org"), "Eve")
|
||||||
|
|
||||||
|
fun aDavid(): RoomMember = aRoomMember(UserId("@david:server.org"), "David")
|
||||||
|
|
||||||
|
fun aCarol(): RoomMember = aRoomMember(UserId("@carol:server.org"), "Carol")
|
||||||
|
|
||||||
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin)
|
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin)
|
||||||
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator)
|
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator)
|
||||||
|
|
||||||
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
|
fun anInvitedVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
|
||||||
|
|
||||||
fun aWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
|
fun anInvitedWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
|
||||||
|
|
||||||
|
fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), "Susie", membership = RoomMembershipState.BAN)
|
||||||
|
|
||||||
|
fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), "Mallory", membership = RoomMembershipState.BAN)
|
||||||
|
|
||||||
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)
|
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,9 @@
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
|
@ -30,10 +25,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -46,15 +38,17 @@ import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.roomdetails.impl.R
|
import io.element.android.features.roomdetails.impl.R
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||||
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
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.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
import io.element.android.libraries.designsystem.theme.components.SearchField
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
|
@ -68,17 +62,11 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
private enum class SelectedSection {
|
|
||||||
MEMBERS,
|
|
||||||
BANNED
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomMemberListView(
|
fun RoomMemberListView(
|
||||||
state: RoomMemberListState,
|
state: RoomMemberListState,
|
||||||
navigator: RoomMemberListNavigator,
|
navigator: RoomMemberListNavigator,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialSelectedSectionIndex: Int = 0,
|
|
||||||
) {
|
) {
|
||||||
fun onSelectUser(roomMember: RoomMember) {
|
fun onSelectUser(roomMember: RoomMember) {
|
||||||
state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
|
state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
|
||||||
|
|
@ -87,21 +75,13 @@ fun RoomMemberListView(
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
if (!state.isSearchActive) {
|
|
||||||
RoomMemberListTopBar(
|
RoomMemberListTopBar(
|
||||||
canInvite = state.canInvite,
|
canInvite = state.canInvite,
|
||||||
onBackClick = navigator::exitRoomMemberList,
|
onBackClick = navigator::exitRoomMemberList,
|
||||||
onInviteClick = navigator::openInviteMembers,
|
onInviteClick = navigator::openInviteMembers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
) { padding ->
|
) { padding ->
|
||||||
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
|
|
||||||
if (!state.moderationState.canBan && selectedSection == SelectedSection.BANNED) {
|
|
||||||
SideEffect {
|
|
||||||
selectedSection = SelectedSection.MEMBERS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -109,45 +89,43 @@ fun RoomMemberListView(
|
||||||
.consumeWindowInsets(padding),
|
.consumeWindowInsets(padding),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
RoomMemberSearchBar(
|
var searchQuery by textFieldState(state.searchQuery)
|
||||||
query = state.searchQuery,
|
SearchField(
|
||||||
state = state.searchResults,
|
value = searchQuery,
|
||||||
active = state.isSearchActive,
|
onValueChange = { newQuery ->
|
||||||
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
|
searchQuery = newQuery
|
||||||
onActiveChange = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
|
state.eventSink(RoomMemberListEvents.UpdateSearchQuery(newQuery))
|
||||||
onTextChange = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
|
},
|
||||||
onSelectUser = ::onSelectUser,
|
modifier = Modifier
|
||||||
selectedSection = selectedSection,
|
.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.padding(horizontal = 16.dp),
|
||||||
|
placeholder = stringResource(CommonStrings.common_search_for_someone),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!state.isSearchActive) {
|
|
||||||
RoomMemberList(
|
RoomMemberList(
|
||||||
roomMembers = state.roomMembers,
|
roomMembersData = state.filteredRoomMembers,
|
||||||
showMembersCount = true,
|
selectedSection = state.selectedSection,
|
||||||
canDisplayBannedUsersControls = state.moderationState.canBan,
|
showBannedSection = state.showBannedSection,
|
||||||
selectedSection = selectedSection,
|
searchQuery = state.searchQuery,
|
||||||
onSelectedSectionChange = { selectedSection = it },
|
onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
|
||||||
onSelectUser = ::onSelectUser,
|
onSelectUser = ::onSelectUser,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomMemberList(
|
private fun RoomMemberList(
|
||||||
roomMembers: AsyncData<RoomMembers>,
|
roomMembersData: AsyncData<RoomMembers>,
|
||||||
showMembersCount: Boolean,
|
|
||||||
selectedSection: SelectedSection,
|
selectedSection: SelectedSection,
|
||||||
|
showBannedSection: Boolean,
|
||||||
|
searchQuery: String,
|
||||||
onSelectedSectionChange: (SelectedSection) -> Unit,
|
onSelectedSectionChange: (SelectedSection) -> Unit,
|
||||||
canDisplayBannedUsersControls: Boolean,
|
|
||||||
onSelectUser: (RoomMember) -> Unit,
|
onSelectUser: (RoomMember) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
|
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
Column {
|
Column {
|
||||||
if (canDisplayBannedUsersControls) {
|
AnimatedVisibility(visible = showBannedSection) {
|
||||||
val segmentedButtonTitles = persistentListOf(
|
val segmentedButtonTitles = persistentListOf(
|
||||||
stringResource(id = R.string.screen_room_member_list_mode_members),
|
stringResource(id = R.string.screen_room_member_list_mode_members),
|
||||||
stringResource(id = R.string.screen_room_member_list_mode_banned),
|
stringResource(id = R.string.screen_room_member_list_mode_banned),
|
||||||
|
|
@ -169,24 +147,26 @@ private fun RoomMemberList(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(visible = roomMembersData.isLoading()) {
|
||||||
visible = roomMembers.isLoading(),
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically(),
|
|
||||||
) {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (roomMembers) {
|
when (roomMembersData) {
|
||||||
is AsyncData.Failure -> failureItem(roomMembers.error)
|
is AsyncData.Failure -> failureItem(roomMembersData.error)
|
||||||
is AsyncData.Loading,
|
is AsyncData.Loading,
|
||||||
is AsyncData.Success -> memberItems(
|
is AsyncData.Success -> {
|
||||||
roomMembers = roomMembers.dataOrNull() ?: return@LazyColumn,
|
val roomMembers = roomMembersData.dataOrNull() ?: return@LazyColumn
|
||||||
|
if (roomMembers.isEmpty(selectedSection)) {
|
||||||
|
emptySearchItem(searchQuery)
|
||||||
|
} else {
|
||||||
|
memberItems(
|
||||||
|
roomMembers = roomMembers,
|
||||||
selectedSection = selectedSection,
|
selectedSection = selectedSection,
|
||||||
onSelectUser = onSelectUser,
|
onSelectUser = onSelectUser,
|
||||||
showMembersCount = showMembersCount,
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
AsyncData.Uninitialized -> Unit
|
AsyncData.Uninitialized -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -196,60 +176,47 @@ private fun LazyListScope.memberItems(
|
||||||
roomMembers: RoomMembers,
|
roomMembers: RoomMembers,
|
||||||
selectedSection: SelectedSection,
|
selectedSection: SelectedSection,
|
||||||
onSelectUser: (RoomMember) -> Unit,
|
onSelectUser: (RoomMember) -> Unit,
|
||||||
showMembersCount: Boolean,
|
|
||||||
) {
|
) {
|
||||||
when (selectedSection) {
|
when (selectedSection) {
|
||||||
SelectedSection.MEMBERS -> {
|
SelectedSection.MEMBERS -> {
|
||||||
if (roomMembers.invited.isNotEmpty()) {
|
if (roomMembers.invited.isNotEmpty()) {
|
||||||
roomMemberListSection(
|
roomMemberListSectionHeader(
|
||||||
headerText = {
|
text = {
|
||||||
// TODO Use showMembersCount? iOS seems to always render the number of users, even when searching for users.
|
val memberCount = roomMembers.invited.count()
|
||||||
val invitedCount = roomMembers.invited.count()
|
pluralStringResource(id = R.plurals.screen_room_member_list_pending_header_title, memberCount, memberCount)
|
||||||
pluralStringResource(id = R.plurals.screen_room_member_list_pending_header_title, count = invitedCount, invitedCount)
|
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
roomMemberListSectionItems(
|
||||||
members = roomMembers.invited,
|
members = roomMembers.invited,
|
||||||
onMemberSelected = { onSelectUser(it) }
|
onMemberSelected = { onSelectUser(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (roomMembers.joined.isNotEmpty()) {
|
if (roomMembers.joined.isNotEmpty()) {
|
||||||
roomMemberListSection(
|
roomMemberListSectionHeader(
|
||||||
headerText = {
|
text = {
|
||||||
if (showMembersCount) {
|
|
||||||
val memberCount = roomMembers.joined.count()
|
val memberCount = roomMembers.joined.count()
|
||||||
pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
|
pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
|
||||||
} else {
|
|
||||||
stringResource(id = R.string.screen_room_member_list_room_members_header_title)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
roomMemberListSectionItems(
|
||||||
members = roomMembers.joined,
|
members = roomMembers.joined,
|
||||||
onMemberSelected = { onSelectUser(it) }
|
onMemberSelected = { onSelectUser(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SelectedSection.BANNED -> { // Banned users
|
SelectedSection.BANNED -> {
|
||||||
if (roomMembers.banned.isNotEmpty()) {
|
if (roomMembers.banned.isNotEmpty()) {
|
||||||
roomMemberListSection(
|
roomMemberListSectionHeader(
|
||||||
headerText = null,
|
text = {
|
||||||
|
val memberCount = roomMembers.banned.count()
|
||||||
|
pluralStringResource(id = R.plurals.screen_room_member_list_banned_header_title, memberCount, memberCount)
|
||||||
|
},
|
||||||
|
isCritical = true,
|
||||||
|
)
|
||||||
|
roomMemberListSectionItems(
|
||||||
members = roomMembers.banned,
|
members = roomMembers.banned,
|
||||||
onMemberSelected = { onSelectUser(it) }
|
onMemberSelected = { onSelectUser(it) }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillParentMaxSize()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(bottom = 56.dp)
|
|
||||||
.align(Alignment.Center),
|
|
||||||
text = stringResource(id = R.string.screen_room_member_list_banned_empty),
|
|
||||||
color = ElementTheme.colors.textSecondary,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,21 +235,25 @@ private fun LazyListScope.failureItem(failure: Throwable) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LazyListScope.roomMemberListSection(
|
private fun LazyListScope.roomMemberListSectionHeader(
|
||||||
headerText: @Composable (() -> String)?,
|
text: @Composable (() -> String),
|
||||||
members: ImmutableList<RoomMemberWithIdentityState>?,
|
modifier: Modifier = Modifier,
|
||||||
onMemberSelected: (RoomMember) -> Unit,
|
isCritical: Boolean = false,
|
||||||
) {
|
) {
|
||||||
headerText?.let {
|
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
text = it(),
|
text = text(),
|
||||||
style = ElementTheme.typography.fontBodyLgRegular,
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = if (isCritical) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.roomMemberListSectionItems(
|
||||||
|
members: ImmutableList<RoomMemberWithIdentityState>?,
|
||||||
|
onMemberSelected: (RoomMember) -> Unit,
|
||||||
|
) {
|
||||||
items(members.orEmpty()) { matrixUser ->
|
items(members.orEmpty()) { matrixUser ->
|
||||||
RoomMemberListItem(
|
RoomMemberListItem(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -292,6 +263,22 @@ private fun LazyListScope.roomMemberListSection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.emptySearchItem(searchQuery: String) {
|
||||||
|
item {
|
||||||
|
IconTitleSubtitleMolecule(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 32.dp),
|
||||||
|
iconStyle = BigIcon.Style.Default(
|
||||||
|
vectorIcon = CompoundIcons.Search(),
|
||||||
|
contentDescription = null,
|
||||||
|
),
|
||||||
|
title = stringResource(R.string.screen_room_member_list_empty_search_title, searchQuery),
|
||||||
|
subTitle = stringResource(R.string.screen_room_member_list_empty_search_subtitle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomMemberListItem(
|
private fun RoomMemberListItem(
|
||||||
roomMemberWithIdentity: RoomMemberWithIdentityState,
|
roomMemberWithIdentity: RoomMemberWithIdentityState,
|
||||||
|
|
@ -371,40 +358,6 @@ private fun RoomMemberListTopBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun RoomMemberSearchBar(
|
|
||||||
query: String,
|
|
||||||
state: SearchBarResultState<AsyncData<RoomMembers>>,
|
|
||||||
active: Boolean,
|
|
||||||
placeHolderTitle: String,
|
|
||||||
onActiveChange: (Boolean) -> Unit,
|
|
||||||
onTextChange: (String) -> Unit,
|
|
||||||
onSelectUser: (RoomMember) -> Unit,
|
|
||||||
selectedSection: SelectedSection,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
SearchBar(
|
|
||||||
query = query,
|
|
||||||
onQueryChange = onTextChange,
|
|
||||||
active = active,
|
|
||||||
onActiveChange = onActiveChange,
|
|
||||||
modifier = modifier,
|
|
||||||
placeHolderTitle = placeHolderTitle,
|
|
||||||
resultState = state,
|
|
||||||
resultHandler = { results ->
|
|
||||||
RoomMemberList(
|
|
||||||
roomMembers = results,
|
|
||||||
showMembersCount = false,
|
|
||||||
onSelectUser = { onSelectUser(it) },
|
|
||||||
canDisplayBannedUsersControls = false,
|
|
||||||
selectedSection = selectedSection,
|
|
||||||
onSelectedSectionChange = {},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
|
internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
|
||||||
|
|
@ -413,13 +366,3 @@ internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProv
|
||||||
navigator = object : RoomMemberListNavigator {},
|
navigator = object : RoomMemberListNavigator {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun RoomMemberListViewBannedPreview(@PreviewParameter(RoomMemberListStateBannedProvider::class) state: RoomMemberListState) = ElementPreview {
|
|
||||||
RoomMemberListView(
|
|
||||||
initialSelectedSectionIndex = 1,
|
|
||||||
state = state,
|
|
||||||
navigator = object : RoomMemberListNavigator {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,36 +8,27 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
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.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.time.withTimeout
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
class RoomMemberListPresenterTest {
|
class RoomMemberListPresenterTest {
|
||||||
|
|
@ -45,176 +36,131 @@ class RoomMemberListPresenterTest {
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `member loading is done automatically on start, but is async`() = runTest {
|
fun `initial state is loading`() = runTest {
|
||||||
val room = FakeJoinedRoom(
|
val presenter = createPresenter()
|
||||||
baseRoom = FakeBaseRoom(
|
presenter.test {
|
||||||
updateMembersResult = { Result.success(Unit) },
|
|
||||||
canInviteResult = { Result.success(true) }
|
|
||||||
).apply {
|
|
||||||
// Needed to avoid discarding the loaded members as a partial and invalid result
|
|
||||||
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val presenter = createPresenter(joinedRoom = room)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.roomMembers.isLoading()).isTrue()
|
assertThat(initialState.filteredRoomMembers.isLoading()).isTrue()
|
||||||
assertThat(initialState.searchQuery).isEmpty()
|
assertThat(initialState.searchQuery).isEmpty()
|
||||||
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
|
assertThat(initialState.selectedSection).isEqualTo(SelectedSection.MEMBERS)
|
||||||
assertThat(initialState.isSearchActive).isFalse()
|
|
||||||
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
|
||||||
// Skip item while the new members state is processed
|
|
||||||
skipItems(1)
|
|
||||||
val loadedMembersState = awaitItem()
|
|
||||||
assertThat(loadedMembersState.roomMembers.isLoading()).isFalse()
|
|
||||||
assertThat(loadedMembersState.roomMembers.dataOrNull()?.invited)
|
|
||||||
.isEqualTo(listOf(RoomMemberWithIdentityState(aVictor(), null), RoomMemberWithIdentityState(aWalter(), null)))
|
|
||||||
assertThat(loadedMembersState.roomMembers.dataOrNull()?.joined).isNotEmpty()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `member loading is done automatically when RoomInfo's activeMemberCount changes`() = runTest {
|
fun `hide banned section when there is no banned users`() = runTest {
|
||||||
val reloadMembersMutex = Mutex()
|
val allRoomMembers = aRoomMemberList()
|
||||||
val updateMembersLambda = lambdaRecorder<Unit> {
|
val noBannedMembers = allRoomMembers
|
||||||
if (reloadMembersMutex.isLocked) {
|
.filterNot { it.membership == RoomMembershipState.BAN }
|
||||||
reloadMembersMutex.unlock()
|
.toImmutableList()
|
||||||
|
val room = createFakeJoinedRoom()
|
||||||
|
.apply {
|
||||||
|
givenRoomMembersState(RoomMembersState.Ready(allRoomMembers))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
val room = FakeJoinedRoom(
|
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
updateMembersResult = updateMembersLambda,
|
|
||||||
canInviteResult = { Result.success(true) }
|
|
||||||
).apply {
|
|
||||||
// Needed to avoid discarding the loaded members as a partial and invalid result
|
|
||||||
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val presenter = createPresenter(joinedRoom = room)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
|
||||||
val initialState = awaitItem()
|
|
||||||
assertThat(initialState.roomMembers.isLoading()).isTrue()
|
|
||||||
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
|
||||||
// Skip item while the new members state is processed
|
|
||||||
skipItems(1)
|
|
||||||
val loadedMembersState = awaitItem()
|
|
||||||
assertThat(loadedMembersState.roomMembers.isLoading()).isFalse()
|
|
||||||
assertThat(loadedMembersState.roomMembers.dataOrNull()?.joined).isNotEmpty()
|
|
||||||
|
|
||||||
// Assert no events are emitted only with that change
|
|
||||||
expectNoEvents()
|
|
||||||
|
|
||||||
// This will only progress if the `Room.updateMembers()` function is called, triggered by the RoomInfo change
|
|
||||||
withTimeout(10.seconds) {
|
|
||||||
reloadMembersMutex.withLock {
|
|
||||||
launch { room.givenRoomInfo(aRoomInfo(activeMembersCount = 0L)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the room members state as `Room.updateMembers()` would have done with the actual implementation
|
|
||||||
room.givenRoomMembersState(RoomMembersState.Ready(persistentListOf()))
|
|
||||||
// Wait for another update
|
|
||||||
skipItems(1)
|
|
||||||
// The members should be reloaded now
|
|
||||||
assertThat(awaitItem().roomMembers.dataOrNull()?.joined).isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `open search`() = runTest {
|
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
joinedRoom = FakeJoinedRoom(
|
joinedRoom = room,
|
||||||
baseRoom = FakeBaseRoom(
|
roomMemberModerationState = aRoomMemberModerationState(canBan = true),
|
||||||
updateMembersResult = { Result.success(Unit) },
|
|
||||||
canInviteResult = { Result.success(true) }
|
|
||||||
)
|
)
|
||||||
)
|
presenter.test {
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
assertThat(loadedState.showBannedSection).isTrue()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.ChangeSelectedSection(SelectedSection.BANNED))
|
||||||
|
val bannedSectionState = awaitItem()
|
||||||
|
assertThat(bannedSectionState.selectedSection).isEqualTo(SelectedSection.BANNED)
|
||||||
|
// Now update the room members to have no banned users
|
||||||
|
room.givenRoomMembersState(RoomMembersState.Ready(noBannedMembers))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val searchActiveState = awaitItem()
|
val noBannedMembersState = awaitItem()
|
||||||
assertThat(searchActiveState.isSearchActive).isTrue()
|
assertThat(noBannedMembersState.showBannedSection).isFalse()
|
||||||
|
skipItems(1)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.selectedSection).isEqualTo(SelectedSection.MEMBERS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `member loading is done automatically on start, but is async`() = runTest {
|
||||||
|
val room = createFakeJoinedRoom()
|
||||||
|
val presenter = createPresenter(joinedRoom = room)
|
||||||
|
presenter.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.filteredRoomMembers.isLoading()).isTrue()
|
||||||
|
assertThat(initialState.searchQuery).isEmpty()
|
||||||
|
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||||
|
// Skip items while the new members state is processed
|
||||||
|
skipItems(2)
|
||||||
|
val loadedState = awaitItem()
|
||||||
|
val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!!
|
||||||
|
assertThat(loadedRoomMembers.joined).isNotEmpty()
|
||||||
|
assertThat(loadedRoomMembers.banned).isNotEmpty()
|
||||||
|
assertThat(loadedRoomMembers.invited).isNotEmpty()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `search for something which is not found`() = runTest {
|
fun `search for something which is not found`() = runTest {
|
||||||
val presenter = createPresenter(
|
val room = createFakeJoinedRoom().apply {
|
||||||
joinedRoom = FakeJoinedRoom(
|
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||||
baseRoom = FakeBaseRoom(
|
}
|
||||||
updateMembersResult = { Result.success(Unit) },
|
val presenter = createPresenter(joinedRoom = room)
|
||||||
canInviteResult = { Result.success(true) }
|
presenter.test {
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!!
|
||||||
val searchActiveState = awaitItem()
|
assertThat(loadedRoomMembers.joined).isNotEmpty()
|
||||||
searchActiveState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
|
assertThat(loadedRoomMembers.banned).isNotEmpty()
|
||||||
skipItems(1)
|
assertThat(loadedRoomMembers.invited).isNotEmpty()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
|
||||||
val searchQueryUpdatedState = awaitItem()
|
val searchQueryUpdatedState = awaitItem()
|
||||||
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("something")
|
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("something")
|
||||||
val searchSearchResultDelivered = awaitItem()
|
val searchSearchResultDelivered = awaitItem()
|
||||||
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
|
val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!!
|
||||||
|
assertThat(emptyRoomMembers.joined).isEmpty()
|
||||||
|
assertThat(emptyRoomMembers.banned).isEmpty()
|
||||||
|
assertThat(emptyRoomMembers.invited).isEmpty()
|
||||||
|
assertThat(emptyRoomMembers.isEmpty(SelectedSection.MEMBERS)).isTrue()
|
||||||
|
assertThat(emptyRoomMembers.isEmpty(SelectedSection.BANNED)).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `search for something which is found`() = runTest {
|
fun `search for something which is found`() = runTest {
|
||||||
val presenter = createPresenter(
|
val room = createFakeJoinedRoom().apply {
|
||||||
joinedRoom = FakeJoinedRoom(
|
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||||
baseRoom = FakeBaseRoom(
|
}
|
||||||
updateMembersResult = { Result.success(Unit) },
|
val presenter = createPresenter(joinedRoom = room)
|
||||||
canInviteResult = { Result.success(true) }
|
presenter.test {
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!!
|
||||||
val searchActiveState = awaitItem()
|
assertThat(loadedRoomMembers.joined).isNotEmpty()
|
||||||
searchActiveState.eventSink(RoomMemberListEvents.UpdateSearchQuery("Alice"))
|
assertThat(loadedRoomMembers.banned).isNotEmpty()
|
||||||
skipItems(1)
|
assertThat(loadedRoomMembers.invited).isNotEmpty()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("alice"))
|
||||||
val searchQueryUpdatedState = awaitItem()
|
val searchQueryUpdatedState = awaitItem()
|
||||||
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice")
|
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("alice")
|
||||||
val searchSearchResultDelivered = awaitItem()
|
val searchSearchResultDelivered = awaitItem()
|
||||||
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!!
|
||||||
assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().roomMember.displayName)
|
assertThat(emptyRoomMembers.joined).isNotEmpty()
|
||||||
.isEqualTo("Alice")
|
assertThat(emptyRoomMembers.banned).isEmpty()
|
||||||
|
assertThat(emptyRoomMembers.invited).isEmpty()
|
||||||
|
assertThat(emptyRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
|
||||||
|
assertThat(emptyRoomMembers.isEmpty(SelectedSection.BANNED)).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
|
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter()
|
||||||
joinedRoom = FakeJoinedRoom(
|
presenter.test {
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
canInviteResult = { Result.success(true) },
|
|
||||||
updateMembersResult = { Result.success(Unit) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.canInvite).isTrue()
|
assertThat(loadedState.canInvite).isTrue()
|
||||||
|
|
@ -224,17 +170,11 @@ class RoomMemberListPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
|
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
joinedRoom = FakeJoinedRoom(
|
joinedRoom = createFakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
canInviteResult = { Result.success(false) },
|
canInviteResult = { Result.success(false) },
|
||||||
updateMembersResult = { Result.success(Unit) }
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
presenter.test {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.canInvite).isFalse()
|
assertThat(loadedState.canInvite).isFalse()
|
||||||
}
|
}
|
||||||
|
|
@ -243,70 +183,54 @@ class RoomMemberListPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
|
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
joinedRoom = FakeJoinedRoom(
|
joinedRoom = createFakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
canInviteResult = { Result.failure(RuntimeException("Eek")) },
|
canInviteResult = { Result.failure(RuntimeException("Eek")) },
|
||||||
updateMembersResult = { Result.success(Unit) }
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
presenter.test {
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.canInvite).isFalse()
|
assertThat(loadedState.canInvite).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - RoomMemberSelected will open the moderation options when target user is not banned`() = runTest {
|
fun `present - RoomMemberSelected will open the moderation options`() = runTest {
|
||||||
val roomMemberModerationPresenter = Presenter {
|
|
||||||
aRoomMemberModerationState(canBan = true, canKick = true)
|
|
||||||
}
|
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
roomMemberModerationPresenter = roomMemberModerationPresenter,
|
roomMemberModerationState = aRoomMemberModerationState(canBan = true, canKick = true)
|
||||||
joinedRoom = FakeJoinedRoom(
|
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
updateMembersResult = { Result.success(Unit) },
|
|
||||||
canInviteResult = { Result.success(true) }
|
|
||||||
)
|
)
|
||||||
)
|
presenter.test {
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
|
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(anInvitedVictor()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
private fun createFakeJoinedRoom(
|
||||||
private fun TestScope.createDataSource(
|
updateMembersResult: () -> Unit = { },
|
||||||
room: BaseRoom = FakeBaseRoom().apply {
|
canInviteResult: (UserId) -> Result<Boolean> = { Result.success(true) },
|
||||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
): FakeJoinedRoom {
|
||||||
},
|
return FakeJoinedRoom(
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
baseRoom = FakeBaseRoom(
|
||||||
) = RoomMemberListDataSource(room, coroutineDispatchers)
|
updateMembersResult = updateMembersResult,
|
||||||
|
canInviteResult = canInviteResult,
|
||||||
|
).apply {
|
||||||
|
// Needed to avoid discarding the loaded members as a partial and invalid result
|
||||||
|
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
private fun TestScope.createPresenter(
|
private fun TestScope.createPresenter(
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||||
joinedRoom: JoinedRoom = FakeJoinedRoom(
|
joinedRoom: JoinedRoom = createFakeJoinedRoom(),
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
updateMembersResult = { Result.success(Unit) }
|
|
||||||
)
|
|
||||||
),
|
|
||||||
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
|
|
||||||
encryptedService: FakeEncryptionService = FakeEncryptionService(),
|
encryptedService: FakeEncryptionService = FakeEncryptionService(),
|
||||||
roomMemberModerationPresenter: Presenter<RoomMemberModerationState> = Presenter {
|
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
||||||
aRoomMemberModerationState()
|
|
||||||
},
|
|
||||||
) = RoomMemberListPresenter(
|
) = RoomMemberListPresenter(
|
||||||
room = joinedRoom,
|
room = joinedRoom,
|
||||||
roomMemberListDataSource = roomMemberListDataSource,
|
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
coroutineDispatchers = coroutineDispatchers,
|
||||||
roomMembersModerationPresenter = roomMemberModerationPresenter,
|
roomMembersModerationPresenter = Presenter {
|
||||||
|
roomMemberModerationState
|
||||||
|
},
|
||||||
encryptionService = encryptedService,
|
encryptionService = encryptedService,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -232,12 +232,12 @@ private fun RoomMemberActionsBottomSheet(
|
||||||
avatarData = user.getAvatarData(size = AvatarSize.RoomListManageUser),
|
avatarData = user.getAvatarData(size = AvatarSize.RoomListManageUser),
|
||||||
avatarType = AvatarType.User,
|
avatarType = AvatarType.User,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 28.dp)
|
.padding(bottom = 24.dp)
|
||||||
.align(Alignment.CenterHorizontally)
|
.align(Alignment.CenterHorizontally)
|
||||||
)
|
)
|
||||||
user.displayName?.let {
|
val bestName = user.getBestName()
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = bestName,
|
||||||
style = ElementTheme.typography.fontHeadingLgBold,
|
style = ElementTheme.typography.fontHeadingLgBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|
@ -246,10 +246,11 @@ private fun RoomMemberActionsBottomSheet(
|
||||||
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
|
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
// Show user ID only if it's different from the display name
|
||||||
|
if (bestName != user.userId.value) {
|
||||||
Text(
|
Text(
|
||||||
text = user.userId.value,
|
text = user.userId.value,
|
||||||
style = ElementTheme.typography.fontBodyLgRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|
@ -258,6 +259,7 @@ private fun RoomMemberActionsBottomSheet(
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
for (actionState in actions) {
|
for (actionState in actions) {
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class LeaveSpacePresenter(
|
||||||
}
|
}
|
||||||
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
|
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
|
||||||
selectableSpaceRooms = leaveSpaceRooms.map {
|
selectableSpaceRooms = leaveSpaceRooms.map {
|
||||||
it?.others.orEmpty().map { room ->
|
it.others.map { room ->
|
||||||
SelectableSpaceRoom(
|
SelectableSpaceRoom(
|
||||||
spaceRoom = room.spaceRoom,
|
spaceRoom = room.spaceRoom,
|
||||||
isLastAdmin = room.isLastAdmin,
|
isLastAdmin = room.isLastAdmin,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class LeaveSpacePresenterTest {
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
|
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
|
||||||
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
|
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
skipItems(3)
|
skipItems(2)
|
||||||
val stateError = awaitItem()
|
val stateError = awaitItem()
|
||||||
assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
|
assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
|
||||||
// Retry
|
// Retry
|
||||||
|
|
@ -84,7 +84,7 @@ class LeaveSpacePresenterTest {
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.spaceName).isNull()
|
assertThat(state.spaceName).isNull()
|
||||||
skipItems(3)
|
skipItems(2)
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
|
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
|
||||||
assertThat(finalState.isLastAdmin).isTrue()
|
assertThat(finalState.isLastAdmin).isTrue()
|
||||||
|
|
@ -120,7 +120,7 @@ class LeaveSpacePresenterTest {
|
||||||
presenter.test {
|
presenter.test {
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.spaceName).isNull()
|
assertThat(state.spaceName).isNull()
|
||||||
skipItems(3)
|
skipItems(2)
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
// The current state is not in the sub room list
|
// The current state is not in the sub room list
|
||||||
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!.map { it.spaceRoom.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_3)
|
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!.map { it.spaceRoom.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_3)
|
||||||
|
|
@ -154,7 +154,7 @@ class LeaveSpacePresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(4)
|
skipItems(3)
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.spaceName).isNull()
|
assertThat(state.spaceName).isNull()
|
||||||
assertThat(state.isLastAdmin).isFalse()
|
assertThat(state.isLastAdmin).isFalse()
|
||||||
|
|
@ -218,7 +218,7 @@ class LeaveSpacePresenterTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(4)
|
skipItems(3)
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
state.eventSink(LeaveSpaceEvents.LeaveSpace)
|
state.eventSink(LeaveSpaceEvents.LeaveSpace)
|
||||||
val stateLeaving = awaitItem()
|
val stateLeaving = awaitItem()
|
||||||
|
|
|
||||||
|
|
@ -163,14 +163,14 @@ suspend inline fun <T> runUpdatingState(
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun <T, R> AsyncData<T>.map(
|
inline fun <T, R> AsyncData<T>.map(
|
||||||
transform: (T?) -> R,
|
transform: (T) -> R,
|
||||||
): AsyncData<R> {
|
): AsyncData<R> {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is AsyncData.Failure -> AsyncData.Failure(
|
is AsyncData.Failure -> AsyncData.Failure(
|
||||||
error = error,
|
error = error,
|
||||||
prevData = transform(prevData)
|
prevData = prevData?.let { transform(prevData) }
|
||||||
)
|
)
|
||||||
is AsyncData.Loading -> AsyncData.Loading(transform(prevData))
|
is AsyncData.Loading -> AsyncData.Loading(prevData?.let { transform(prevData) })
|
||||||
is AsyncData.Success -> AsyncData.Success(transform(data))
|
is AsyncData.Success -> AsyncData.Success(transform(data))
|
||||||
AsyncData.Uninitialized -> AsyncData.Uninitialized
|
AsyncData.Uninitialized -> AsyncData.Uninitialized
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ enum class AvatarSize(val dp: Dp) {
|
||||||
InviteSender(16.dp),
|
InviteSender(16.dp),
|
||||||
|
|
||||||
EditRoomDetails(70.dp),
|
EditRoomDetails(70.dp),
|
||||||
RoomListManageUser(70.dp),
|
RoomListManageUser(96.dp),
|
||||||
|
|
||||||
NotificationsOptIn(32.dp),
|
NotificationsOptIn(32.dp),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
/*
|
||||||
|
* 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.designsystem.theme.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1985-3223
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SearchField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String? = null,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier,
|
||||||
|
textStyle = textFieldStyle(),
|
||||||
|
singleLine = true,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(ElementTheme.colors.textActionAccent),
|
||||||
|
) { innerTextField ->
|
||||||
|
DecorationBox(
|
||||||
|
isFocused = isFocused,
|
||||||
|
placeholder = placeholder,
|
||||||
|
isTextEmpty = value.isEmpty(),
|
||||||
|
innerTextField = innerTextField,
|
||||||
|
onClear = { onValueChange("") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchField(
|
||||||
|
value: TextFieldValue,
|
||||||
|
onValueChange: (TextFieldValue) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String? = null,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier,
|
||||||
|
textStyle = textFieldStyle(),
|
||||||
|
singleLine = true,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onSearch = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(ElementTheme.colors.textActionAccent),
|
||||||
|
) { innerTextField ->
|
||||||
|
DecorationBox(
|
||||||
|
isFocused = isFocused,
|
||||||
|
placeholder = placeholder,
|
||||||
|
isTextEmpty = value.text.isEmpty(),
|
||||||
|
innerTextField = innerTextField,
|
||||||
|
onClear = { TextFieldValue() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DecorationBox(
|
||||||
|
isFocused: Boolean,
|
||||||
|
placeholder: String?,
|
||||||
|
isTextEmpty: Boolean,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
innerTextField: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
SearchFieldContainer(
|
||||||
|
isFocused = isFocused,
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
if (placeholder != null && isTextEmpty) {
|
||||||
|
Text(
|
||||||
|
text = placeholder,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
val showClearIcon = isFocused && !isTextEmpty
|
||||||
|
IconButton(onClick = onClear, enabled = showClearIcon) {
|
||||||
|
if (showClearIcon) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.background(ElementTheme.colors.iconSecondary, CircleShape),
|
||||||
|
imageVector = CompoundIcons.Close(),
|
||||||
|
contentDescription = stringResource(CommonStrings.action_clear),
|
||||||
|
tint = ElementTheme.colors.iconOnSolidPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = CompoundIcons.Search(),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ElementTheme.colors.iconTertiary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchFieldContainer(
|
||||||
|
isFocused: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(99.dp),
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (isFocused) {
|
||||||
|
ElementTheme.colors.borderInteractiveHovered
|
||||||
|
} else {
|
||||||
|
ElementTheme.colors.borderInteractiveSecondary
|
||||||
|
}
|
||||||
|
),
|
||||||
|
color = ElementTheme.colors.bgSubtleSecondary,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun textFieldStyle(): TextStyle {
|
||||||
|
return ElementTheme.typography.fontBodyLgRegular.copy(
|
||||||
|
color = ElementTheme.colors.textPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(group = PreviewGroup.Search, heightDp = 1000)
|
||||||
|
@Composable
|
||||||
|
internal fun SearchFieldsLightPreview() = ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview(group = PreviewGroup.Search, heightDp = 1000)
|
||||||
|
@Composable
|
||||||
|
internal fun SearchFieldsDarkPreview() = ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@ExcludeFromCoverage
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
verticalArrangement = spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
SearchField(
|
||||||
|
onValueChange = {},
|
||||||
|
placeholder = "Search",
|
||||||
|
value = "",
|
||||||
|
)
|
||||||
|
SearchField(
|
||||||
|
onValueChange = {},
|
||||||
|
placeholder = "Search",
|
||||||
|
value = "Search term",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -116,7 +116,6 @@ class KonsistPreviewTest {
|
||||||
"ProgressDialogWithContentPreview",
|
"ProgressDialogWithContentPreview",
|
||||||
"ProgressDialogWithTextAndContentPreview",
|
"ProgressDialogWithTextAndContentPreview",
|
||||||
"ReadReceiptBottomSheetPreview",
|
"ReadReceiptBottomSheetPreview",
|
||||||
"RoomMemberListViewBannedPreview",
|
|
||||||
"SasEmojisPreview",
|
"SasEmojisPreview",
|
||||||
"SecureBackupSetupViewChangePreview",
|
"SecureBackupSetupViewChangePreview",
|
||||||
"SelectedUserCannotRemovePreview",
|
"SelectedUserCannotRemovePreview",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5070188463df330ea8f7969228f436dba0db5dd9eb9280f5b4484296176bf3e9
|
|
||||||
size 11503
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:4f544e2018c0cd221e6bba338e61af4f306809d10d37bd5b20c33e0fa67cbabb
|
|
||||||
size 11741
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5070188463df330ea8f7969228f436dba0db5dd9eb9280f5b4484296176bf3e9
|
|
||||||
size 11503
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:07dc1c12e55e3eb3ce6ee01582535641a08d62d4b059bdd581af6c58063ca70c
|
|
||||||
size 10849
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:1fe0b9379ed8bd9a3c19c7cff5eb6085657e1453e4f8d6ebe88a3338e738236c
|
|
||||||
size 11067
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:07dc1c12e55e3eb3ce6ee01582535641a08d62d4b059bdd581af6c58063ca70c
|
|
||||||
size 10849
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:08172934ef864d57cb335b738dea8ad6f96ee2df4710c4f9d7e5adc3d110ad67
|
oid sha256:367bc0df8b1839af2b434de55ea3815af4a8c112cc96e98ee76c3b9a4949fe52
|
||||||
size 44215
|
size 12985
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ad1fce13a5034b6db192a0735317b6cf5f10536ac05ccf1cc4996ad029ce9513
|
oid sha256:1700a0f9e6f9115015849c900d7faa27f70fa29752bde7666eeb36cf4eb1a6da
|
||||||
size 49951
|
size 19463
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4f544e2018c0cd221e6bba338e61af4f306809d10d37bd5b20c33e0fa67cbabb
|
oid sha256:c8b5970490e96a693655df832f9c4bf83dd55a8b06da27b48881d9f61b2dfd9c
|
||||||
size 11741
|
size 55110
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cad1aae139861f1ba83d3b03a6d78916c2ced5f19e6e99b935b4a49c1b5d12bb
|
oid sha256:bae192229d384047dcf5fb6744267a9e448d7f0a4f933a4984a0d1c3b9d07da2
|
||||||
size 12686
|
size 30789
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4f544e2018c0cd221e6bba338e61af4f306809d10d37bd5b20c33e0fa67cbabb
|
oid sha256:6b7049a6865a0ea4529cc43d16a27b9107e26d6583f429affe8327840b84ac2a
|
||||||
size 11741
|
size 56050
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fd50f1d35f872ee93bd6b2aff1feaffcc2314b11fae60302351f738bd86d735c
|
oid sha256:0b4a208d39a5f6040d17da5290a8f2f81cd917aa02c8e38b092fc79497e1e563
|
||||||
size 7630
|
size 18394
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8c0c23f8cb90609cc1f6107f4d34efbc044de92286cb237487993953f5497e5e
|
oid sha256:acfaec83e840845137f9c07d1325e24f106bc67ccdbf6c548e77efe1d490596b
|
||||||
size 6521
|
size 29428
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7f509f0c857c9d2c0a23657d213ea49961bb900b652edd116c4dcf29853c7776
|
|
||||||
size 24336
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:58d4c3e636f71ac1408189c3c14da3b0dd1e700a1be02a02f373adbcb5604b48
|
|
||||||
size 10976
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:cba49da736f96c2dd8ca3822e796930f4bd6ff3497b72f394d034147178906ce
|
|
||||||
size 18239
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bd62c76e687e342f46b792958ddd3c36d44f5c603e4ddbe79f4fd2a8e41b9750
|
oid sha256:7b01cfde0e59d64a9e6d576e22e2ac9eeab5ab1a99cb0f9f54d1c8cec80ac4bd
|
||||||
size 44198
|
size 12154
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cc7dd1459c04997170247296b34b9bd7953418361c2d72840d43aa159000a806
|
oid sha256:2cb19c23879eb9698729561fb1ef79c1e65a36eab0f24681e4cf72dfdde45d04
|
||||||
size 49655
|
size 18281
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1fe0b9379ed8bd9a3c19c7cff5eb6085657e1453e4f8d6ebe88a3338e738236c
|
oid sha256:889734242ce96bfc206ae1421ab9c1cbccbcb11f99889445ea27833cbd057a0a
|
||||||
size 11067
|
size 55231
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:34198a1ba6c0f6385eb8732d36622ee0d6f865c72085d95d378d82ed7afe9486
|
oid sha256:dc20039c1c5d456f318f22e524360d411d4a4980b6a2a9312c0707c0fe865340
|
||||||
size 11936
|
size 29867
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1fe0b9379ed8bd9a3c19c7cff5eb6085657e1453e4f8d6ebe88a3338e738236c
|
oid sha256:5bb86c6fea38d0e798cffa5fee0610a32048aeb1d3427cd9b5b749dfacee0e55
|
||||||
size 11067
|
size 56063
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:11c23632578610a78a6700f9d103ff6b4b3da03887cfb1918b10f2c6052e6820
|
oid sha256:a6d55832abbcffc4d84b7139fbfb19d4c96a5ebcc9233ff139c5aae6b9f406d9
|
||||||
size 7549
|
size 17831
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b838359a59cdc357f0ef80d701f6093ea45875c7915ba438901748f43ce9106b
|
oid sha256:3802b661c6fc656f621ba3c368eeb93f004561b01926faa572c4ba5ee66d07eb
|
||||||
size 6316
|
size 28474
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:bd6d96abbeda8ab8dd7804929ed205cd2c1eb8bb04d83d00a343387e93bc693b
|
|
||||||
size 24297
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ced65352d0aee02e92f19fc890a8fa92efabfb8255a9a58e36dc539e2d4d6009
|
|
||||||
size 10664
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d9f910e14188c705d993201bdebab2f818badc71272195aa4cce31728315beea
|
|
||||||
size 17207
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:54e526b8ab19ec121349dfc61f751b4d301c61ec47f1c1ec1c02d120b1f4faaf
|
oid sha256:f333d59f48ec9ff036e72c727c3bfae32dbb919c35b854906fca153bfc1c84e4
|
||||||
size 17064
|
size 17375
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1be3ac8e92243baeceeb7349ed767e4c39ebfc4880b253e7f2e46ed9fc75da01
|
oid sha256:dd83d242f9099de1ae0c84b07cb773e08dde1ae535ee2fd2bcb002e2e0e22132
|
||||||
size 20222
|
size 20397
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4e8c820a8c4966526e7851b4f3152ccaf9072bbf8ebf21737d35d1c87325bf6f
|
oid sha256:0f3d63a5c888eb1b951be3c46c4c51f7c622d7e7838c35b3de388ac1cd20789d
|
||||||
size 22524
|
size 22793
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:983e163f59ef03f5a7c30cf7073002105a663cec64f8a8d4fc89fa0991bfd730
|
oid sha256:e5ce9706fbc30f9d99c35c9d1f145c7f933295fe3eb62063c640b303526649db
|
||||||
size 22646
|
size 22915
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6db6ff988fd128f537764e5a3fcaa94e67db3057fc7030698adabe70f962c1b4
|
oid sha256:06aad1478e9096e5215bd3116a7bef0e98037383c835de73f57d46a9cfa86137
|
||||||
size 15991
|
size 16415
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e4868e2ce56debe8b175715417190db0037d59d204a28b5c0bc0b0c81a5d91a9
|
oid sha256:71cd9c85c33cc222836ec26f39e7209a954728d5beebadf53a784bd5a0813709
|
||||||
size 18925
|
size 19390
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a59481d4391dd8d0cb749ff63d3bb34846b450a6713ade11a6dbbe0461bb3650
|
oid sha256:66b527092681543f600725e508213f59524b5bdf3f3ceff2bbc47d43cc9d66e8
|
||||||
size 21257
|
size 21685
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:381fbeb4b57f044fb785b65d04b041c4ca5efa89138742b8480a7540ed57b4f0
|
oid sha256:47c1592948df21f0f3d69dead320f50e47ebf8da3bb86bc8302ad392d5813c57
|
||||||
size 21335
|
size 21765
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:39e0775e5c569a95653ab922084c4056635bec2056441771af3c8a3739a84901
|
||||||
|
size 8143
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a97489a6af869148d1daadb5948f26d00e888ecf0373e6ec45eb8a5ed8b12173
|
||||||
|
size 8383
|
||||||
Loading…
Add table
Add a link
Reference in a new issue