refactor (start chat) : use invite people module in room details screen

This commit is contained in:
ganfra 2025-08-08 19:06:19 +02:00 committed by Benoit Marty
parent bfd1182baf
commit 74f6a83219
20 changed files with 207 additions and 543 deletions

View file

@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@ -48,7 +49,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
NavTarget.ConfigureRoom -> {
val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) {
backstack.push(NavTarget.AddPeople(roomId))
backstack.replace(NavTarget.AddPeople(roomId))
}
}
createNode<ConfigureRoomNode>(buildContext, plugins = listOf(callback))

View file

@ -12,7 +12,6 @@ import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@ -27,13 +26,13 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
class AddPeopleNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val invitePeoplePresenterFactory: InvitePeoplePresenter.Factory,
invitePeoplePresenterFactory: InvitePeoplePresenter.Factory,
private val invitePeopleRenderer: InvitePeopleRenderer,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val joinedRoom: JoinedRoom
): NodeInputs
) : NodeInputs
private val joinedRoom = inputs<Inputs>().joinedRoom
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(joinedRoom)
@ -41,6 +40,10 @@ class AddPeopleNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = invitePeoplePresenter.present()
invitePeopleRenderer.Render(state, Modifier)
AddPeopleView(
state = state,
invitePeopleView = { invitePeopleRenderer.Render(state, Modifier) },
onFinish = {}
)
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright 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.createroom.impl.addpeople
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun AddPeopleView(
state: InvitePeopleState,
invitePeopleView: @Composable () -> Unit,
onFinish: () -> Unit,
modifier: Modifier = Modifier,
) {
HeaderFooterPage(
modifier = modifier,
contentPadding = PaddingValues(0.dp),
topBar = {
AddPeopleTopBar(onSkipClick = onFinish)
},
footer = {
Button(
text = "Finish",
onClick = { state.eventSink(InvitePeopleEvents.SendInvites) },
enabled = state.canInvite,
modifier = Modifier.padding(bottom = 16.dp)
)
},
content = invitePeopleView
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddPeopleTopBar(
onSkipClick: () -> Unit,
) {
TopAppBar(
titleStr = "Invite people",
actions = {
TextButton(
text = stringResource(CommonStrings.action_skip),
onClick = onSkipClick,
)
}
)
}

View file

@ -9,4 +9,5 @@ package io.element.android.features.invitepeople.api
interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents
data object CloseSearch : InvitePeopleEvents
}

View file

@ -8,5 +8,7 @@
package io.element.android.features.invitepeople.api
interface InvitePeopleState {
val canInvite: Boolean
val isSearchActive: Boolean
val eventSink: (InvitePeopleEvents) -> Unit
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 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.invitepeople.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
override val values: Sequence<InvitePeopleState>
get() = sequenceOf(
PreviewInvitePeopleState(),
PreviewInvitePeopleState(canInvite = true),
PreviewInvitePeopleState(isSearchActive = true)
)
}
private data class PreviewInvitePeopleState(
override val canInvite: Boolean = false,
override val isSearchActive: Boolean = false,
override val eventSink: (InvitePeopleEvents) -> Unit = {}
) : InvitePeopleState

View file

@ -34,6 +34,7 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.usersearch.impl)
implementation(libs.coil.compose)
implementation(projects.services.apperror.api)
api(projects.features.invitepeople.api)
testImplementation(libs.test.junit)

View file

@ -27,23 +27,30 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.filterMembers
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.services.apperror.api.AppErrorStateService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DefaultInvitePeoplePresenter @AssistedInject constructor(
@Assisted private val room: JoinedRoom,
private val userRepository: UserRepository,
private val coroutineDispatchers: CoroutineDispatchers,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
private val appErrorStateService: AppErrorStateService,
) : InvitePeoplePresenter {
@AssistedFactory
@ -97,12 +104,30 @@ class DefaultInvitePeoplePresenter @AssistedInject constructor(
searchResults.toggleUser(it.user)
}
is InvitePeopleEvents.SendInvites -> {
coroutineScope.sendInvites(selectedUsers.value)
}
is InvitePeopleEvents.CloseSearch -> {
searchActive = false
searchQuery = ""
}
}
}
)
}
private fun CoroutineScope.sendInvites(selectedUsers: List<MatrixUser>) = launch {
val anyInviteFailed = selectedUsers
.map { room.inviteUserById(it.userId) }
.any { it.isFailure }
if (anyInviteFailed) {
appErrorStateService.showError(
titleRes = CommonStrings.common_unable_to_invite_title,
bodyRes = CommonStrings.common_unable_to_invite_message,
)
}
}
@JvmName("toggleUserInSelectedUsers")
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) {

View file

@ -22,8 +22,6 @@ class DefaultInvitePeopleRenderer @Inject constructor() : InvitePeopleRenderer {
if (state is DefaultInvitePeopleState) {
InvitePeopleView(
state = state,
onBackClick = {},
onSubmitClick = {},
modifier = modifier
)
} else {

View file

@ -14,11 +14,11 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class DefaultInvitePeopleState(
val canInvite: Boolean,
override val canInvite: Boolean,
val searchQuery: String,
val showSearchLoader: Boolean,
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
override val isSearchActive: Boolean,
override val eventSink: (InvitePeopleEvents) -> Unit
): InvitePeopleState

View file

@ -7,10 +7,10 @@
package io.element.android.features.invitepeople.impl
import androidx.activity.OnBackPressedDispatcher
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.bumble.appyx.core.plugin.BackPressHandler
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -28,7 +29,6 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
@ -46,87 +46,46 @@ import kotlinx.collections.immutable.ImmutableList
@Composable
fun InvitePeopleView(
state: DefaultInvitePeopleState,
onBackClick: () -> Unit,
onSubmitClick: (List<MatrixUser>) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
RoomInviteMembersTopBar(
onBackClick = {
if (state.isSearchActive) {
state.eventSink(DefaultInvitePeopleEvents.OnSearchActiveChanged(false))
} else {
onBackClick()
}
},
onSubmitClick = { onSubmitClick(state.selectedUsers) },
canSend = state.canInvite,
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RoomInviteMembersSearchBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers,
state = state.searchResults,
active = state.isSearchActive,
onActiveChange = {
state.eventSink(
DefaultInvitePeopleEvents.OnSearchActiveChanged(
it
)
)
},
onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) },
onToggleUser = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
)
if (!state.isSearchActive) {
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = state.selectedUsers,
autoScroll = true,
onUserRemove = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
contentPadding = PaddingValues(16.dp),
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
InvitePeopleSearchBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers,
state = state.searchResults,
active = state.isSearchActive,
onActiveChange = {
state.eventSink(
DefaultInvitePeopleEvents.OnSearchActiveChanged(
it
)
)
}
},
onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) },
onToggleUser = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
)
if (!state.isSearchActive) {
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = state.selectedUsers,
autoScroll = true,
onUserRemove = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
contentPadding = PaddingValues(16.dp),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomInviteMembersTopBar(
canSend: Boolean,
onBackClick: () -> Unit,
onSubmitClick: () -> Unit,
) {
TopAppBar(
titleStr = "Invite people",
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
TextButton(
text = stringResource(CommonStrings.action_invite),
onClick = onSubmitClick,
enabled = canSend,
)
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomInviteMembersSearchBar(
private fun InvitePeopleSearchBar(
query: String,
state: SearchBarResultState<ImmutableList<InvitableUser>>,
showLoader: Boolean,
@ -219,9 +178,5 @@ private fun RoomInviteMembersSearchBar(
@Composable
internal fun RoomInviteMembersViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) =
ElementPreview {
InvitePeopleView(
state = state,
onBackClick = {},
onSubmitClick = {},
)
InvitePeopleView(state = state)
}

View file

@ -56,6 +56,7 @@ dependencies {
implementation(projects.features.reportroom.api)
implementation(projects.features.roommembermoderation.api)
implementation(projects.features.changeroommemberroles.api)
implementation(projects.features.invitepeople.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View file

@ -1,16 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.invite
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface RoomInviteMembersEvents {
data class ToggleUser(val user: MatrixUser) : RoomInviteMembersEvents
data class UpdateSearchQuery(val query: String) : RoomInviteMembersEvents
data class OnSearchActiveChanged(val active: Boolean) : RoomInviteMembersEvents
}

View file

@ -9,7 +9,6 @@ package io.element.android.features.roomdetails.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -18,27 +17,22 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleRenderer
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.apperror.api.AppErrorStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
@ContributesNode(RoomScope::class)
class RoomInviteMembersNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
coroutineDispatchers: CoroutineDispatchers,
private val room: JoinedRoom,
private val presenter: RoomInviteMembersPresenter,
private val appErrorStateService: AppErrorStateService,
private val analyticsService: AnalyticsService,
private val invitePeopleRenderer: InvitePeopleRenderer,
private val invitePeoplePresenterFactory: InvitePeoplePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io)
init {
lifecycle.subscribe(
@ -48,31 +42,17 @@ class RoomInviteMembersNode @AssistedInject constructor(
)
}
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(room)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current.applicationContext
val state = invitePeoplePresenter.present()
RoomInviteMembersView(
state = state,
modifier = modifier,
invitePeopleView = { invitePeopleRenderer.Render(state, Modifier) },
onBackClick = { navigateUp() },
onSubmitClick = { users ->
navigateUp()
coroutineScope.launch {
val anyInviteFailed = users
.map { room.inviteUserById(it.userId) }
.any { it.isFailure }
if (anyInviteFailed) {
appErrorStateService.showError(
title = context.getString(CommonStrings.common_unable_to_invite_title),
body = context.getString(CommonStrings.common_unable_to_invite_message),
)
}
}
}
onSubmitClick = { navigateUp() }
)
}
}

View file

@ -1,154 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomInviteMembersPresenter @Inject constructor(
private val userRepository: UserRepository,
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomInviteMembersState> {
@Composable
override fun present(): RoomInviteMembersState {
val roomMembers = remember { mutableStateOf<AsyncData<ImmutableList<RoomMember>>>(AsyncData.Loading()) }
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
fetchMembers(roomMembers)
}
LaunchedEffect(searchQuery, roomMembers) {
performSearch(
searchResults = searchResults,
roomMembers = roomMembers,
selectedUsers = selectedUsers,
showSearchLoader = showSearchLoader,
searchQuery = searchQuery
)
}
return RoomInviteMembersState(
canInvite = selectedUsers.value.isNotEmpty(),
selectedUsers = selectedUsers.value,
searchQuery = searchQuery,
isSearchActive = searchActive,
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,
eventSink = {
when (it) {
is RoomInviteMembersEvents.OnSearchActiveChanged -> {
searchActive = it.active
searchQuery = ""
}
is RoomInviteMembersEvents.UpdateSearchQuery -> {
searchQuery = it.query
}
is RoomInviteMembersEvents.ToggleUser -> {
selectedUsers.toggleUser(it.user)
searchResults.toggleUser(it.user)
}
}
}
)
}
@JvmName("toggleUserInSelectedUsers")
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) {
value.filterNot { it.userId == user.userId }
} else {
value + user
}.toImmutableList()
}
@JvmName("toggleUserInSearchResults")
private fun MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>.toggleUser(user: MatrixUser) {
val existingResults = value
if (existingResults is SearchBarResultState.Results) {
value = SearchBarResultState.Results(
existingResults.results.map { iu ->
if (iu.matrixUser == user) {
iu.copy(isSelected = !iu.isSelected)
} else {
iu
}
}.toImmutableList()
)
}
}
private suspend fun performSearch(
searchResults: MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>,
roomMembers: MutableState<AsyncData<ImmutableList<RoomMember>>>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
showSearchLoader: MutableState<Boolean>,
searchQuery: String,
) = withContext(coroutineDispatchers.io) {
searchResults.value = SearchBarResultState.Initial()
showSearchLoader.value = false
val joinedMembers = roomMembers.value.dataOrNull().orEmpty()
userRepository.search(searchQuery).onEach { state ->
showSearchLoader.value = state.isSearching
searchResults.value = when {
state.results.isEmpty() && state.isSearching -> SearchBarResultState.Initial()
state.results.isEmpty() && !state.isSearching -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Results(state.results.map { result ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership
val isJoined = existingMembership == RoomMembershipState.JOIN
val isInvited = existingMembership == RoomMembershipState.INVITE
InvitableUser(
matrixUser = result.matrixUser,
isSelected = selectedUsers.value.contains(result.matrixUser) || isJoined || isInvited,
isAlreadyJoined = isJoined,
isAlreadyInvited = isInvited,
isUnresolved = result.isUnresolved,
)
}.toImmutableList())
}
}.launchIn(this)
}
private suspend fun fetchMembers(roomMembers: MutableState<AsyncData<ImmutableList<RoomMember>>>) {
suspend {
withContext(coroutineDispatchers.io) {
roomMemberListDataSource.search("").toImmutableList()
}
}.runCatchingUpdatingState(roomMembers)
}
}

View file

@ -1,30 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.invite
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class RoomInviteMembersState(
val canInvite: Boolean,
val searchQuery: String,
val showSearchLoader: Boolean,
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
val eventSink: (RoomInviteMembersEvents) -> Unit,
)
data class InvitableUser(
val matrixUser: MatrixUser,
val isSelected: Boolean = false,
val isAlreadyJoined: Boolean = false,
val isAlreadyInvited: Boolean = false,
val isUnresolved: Boolean = false,
)

View file

@ -1,89 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInviteMembersState> {
override val values: Sequence<RoomInviteMembersState>
get() = sequenceOf(
aRoomInviteMembersState(),
aRoomInviteMembersState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query"),
aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()),
aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResultsFound()),
aRoomInviteMembersState(
isSearchActive = true,
canInvite = true,
searchQuery = "some query",
selectedUsers = persistentListOf(
aMatrixUser("@carol:server.org", "Carol")
),
searchResults = SearchBarResultState.Results(
persistentListOf(
InvitableUser(aMatrixUser("@alice:server.org")),
InvitableUser(aMatrixUser("@bob:server.org", "Bob")),
InvitableUser(aMatrixUser("@carol:server.org", "Carol"), isSelected = true),
InvitableUser(aMatrixUser("@eve:server.org", "Eve"), isSelected = true, isAlreadyJoined = true),
InvitableUser(aMatrixUser("@justin:server.org", "Justin"), isSelected = true, isAlreadyInvited = true),
)
)
),
aRoomInviteMembersState(
isSearchActive = true,
canInvite = true,
searchQuery = "@alice:server.org",
selectedUsers = persistentListOf(
aMatrixUser("@carol:server.org", "Carol")
),
searchResults = SearchBarResultState.Results(
persistentListOf(
InvitableUser(aMatrixUser("@alice:server.org"), isUnresolved = true),
InvitableUser(aMatrixUser("@bob:server.org", "Bob")),
)
)
),
aRoomInviteMembersState(
isSearchActive = true,
canInvite = true,
searchQuery = "@alice:server.org",
searchResults = SearchBarResultState.Results(
persistentListOf(
InvitableUser(aMatrixUser("@alice:server.org"), isUnresolved = true),
)
),
showSearchLoader = true,
),
)
}
private fun aRoomInviteMembersState(
canInvite: Boolean = false,
searchQuery: String = "",
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false,
showSearchLoader: Boolean = false,
): RoomInviteMembersState {
return RoomInviteMembersState(
canInvite = canInvite,
searchQuery = searchQuery,
searchResults = searchResults,
selectedUsers = selectedUsers,
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
eventSink = {},
)
}

View file

@ -8,47 +8,35 @@
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.features.invitepeople.api.InvitePeopleStateProvider
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
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.SearchBarResultState
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.TopAppBar
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable
fun RoomInviteMembersView(
state: RoomInviteMembersState,
state: InvitePeopleState,
invitePeopleView: @Composable () -> Unit,
onBackClick: () -> Unit,
onSubmitClick: (List<MatrixUser>) -> Unit,
onSubmitClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -57,45 +45,28 @@ fun RoomInviteMembersView(
RoomInviteMembersTopBar(
onBackClick = {
if (state.isSearchActive) {
state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(false))
state.eventSink(InvitePeopleEvents.CloseSearch)
} else {
onBackClick()
}
},
onSubmitClick = { onSubmitClick(state.selectedUsers) },
onSubmitClick = {
state.eventSink(InvitePeopleEvents.SendInvites)
onSubmitClick()
},
canSend = state.canInvite,
)
}
) { padding ->
Column(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding),
) {
RoomInviteMembersSearchBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers,
state = state.searchResults,
active = state.isSearchActive,
onActiveChange = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) },
onTextChange = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) },
onToggleUser = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) },
)
if (!state.isSearchActive) {
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = state.selectedUsers,
autoScroll = true,
onUserRemove = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) },
contentPadding = PaddingValues(16.dp),
)
}
invitePeopleView()
}
}
}
@ -119,100 +90,12 @@ private fun RoomInviteMembersTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomInviteMembersSearchBar(
query: String,
state: SearchBarResultState<ImmutableList<InvitableUser>>,
showLoader: Boolean,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
onTextChange: (String) -> Unit,
onToggleUser: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone),
) {
SearchBar(
query = query,
onQueryChange = onTextChange,
active = active,
onActiveChange = onActiveChange,
modifier = modifier,
placeHolderTitle = placeHolderTitle,
contentPrefix = {
if (selectedUsers.isNotEmpty()) {
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemove = onToggleUser,
contentPadding = PaddingValues(16.dp),
)
}
},
showBackButton = false,
resultState = state,
contentSuffix = {
if (showLoader) {
AsyncLoading()
}
},
resultHandler = { results ->
Text(
text = stringResource(id = CommonStrings.common_search_results),
style = ElementTheme.typography.fontBodyLgMedium,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 8.dp)
)
LazyColumn {
itemsIndexed(results) { index, invitableUser ->
val notInvitedOrJoined = !(invitableUser.isAlreadyInvited || invitableUser.isAlreadyJoined)
val isUnresolved = invitableUser.isUnresolved && notInvitedOrJoined
val enabled = isUnresolved || notInvitedOrJoined
val data = if (isUnresolved) {
CheckableUserRowData.Unresolved(
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem),
id = invitableUser.matrixUser.userId.value,
)
} else {
CheckableUserRowData.Resolved(
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem),
name = invitableUser.matrixUser.getBestName(),
subtext = when {
// If they're already invited or joined we show that information
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member)
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited)
// Otherwise show the ID, unless that's already used for their name
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value
else -> null
}
)
}
CheckableUserRow(
checked = invitableUser.isSelected,
enabled = enabled,
data = data,
onCheckedChange = { onToggleUser(invitableUser.matrixUser) },
modifier = Modifier.fillMaxWidth()
)
if (index < results.lastIndex) {
HorizontalDivider()
}
}
}
},
)
}
@PreviewsDayNight
@Composable
internal fun RoomInviteMembersViewPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview {
internal fun RoomInviteMembersViewPreview(@PreviewParameter(InvitePeopleStateProvider::class) state: InvitePeopleState) = ElementPreview {
RoomInviteMembersView(
state = state,
invitePeopleView = {},
onBackClick = {},
onSubmitClick = {},
)

View file

@ -7,10 +7,14 @@
package io.element.android.services.apperror.api
import androidx.annotation.StringRes
import kotlinx.coroutines.flow.StateFlow
interface AppErrorStateService {
val appErrorStateFlow: StateFlow<AppErrorState>
fun showError(title: String, body: String)
fun showError(@StringRes titleRes: Int, @StringRes bodyRes: Int)
}

View file

@ -7,8 +7,10 @@
package io.element.android.services.apperror.impl
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
@ -18,7 +20,9 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultAppErrorStateService @Inject constructor() : AppErrorStateService {
class DefaultAppErrorStateService @Inject constructor(
@ApplicationContext private val context: Context,
) : AppErrorStateService {
private val currentAppErrorState = MutableStateFlow<AppErrorState>(AppErrorState.NoError)
override val appErrorStateFlow: StateFlow<AppErrorState> = currentAppErrorState
@ -31,4 +35,10 @@ class DefaultAppErrorStateService @Inject constructor() : AppErrorStateService {
},
)
}
override fun showError(titleRes: Int, bodyRes: Int) {
val title = context.getString(titleRes)
val body = context.getString(bodyRes)
showError(title, body)
}
}