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 98343e0531
commit c41994ad13
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.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@ -48,7 +49,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
NavTarget.ConfigureRoom -> { NavTarget.ConfigureRoom -> {
val callback = object : ConfigureRoomNode.Callback { val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) { override fun onCreateRoomSuccess(roomId: RoomId) {
backstack.push(NavTarget.AddPeople(roomId)) backstack.replace(NavTarget.AddPeople(roomId))
} }
} }
createNode<ConfigureRoomNode>(buildContext, plugins = listOf(callback)) 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.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@ -27,13 +26,13 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
class AddPeopleNode @AssistedInject constructor( class AddPeopleNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val invitePeoplePresenterFactory: InvitePeoplePresenter.Factory, invitePeoplePresenterFactory: InvitePeoplePresenter.Factory,
private val invitePeopleRenderer: InvitePeopleRenderer, private val invitePeopleRenderer: InvitePeopleRenderer,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val joinedRoom: JoinedRoom val joinedRoom: JoinedRoom
): NodeInputs ) : NodeInputs
private val joinedRoom = inputs<Inputs>().joinedRoom private val joinedRoom = inputs<Inputs>().joinedRoom
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(joinedRoom) private val invitePeoplePresenter = invitePeoplePresenterFactory.create(joinedRoom)
@ -41,6 +40,10 @@ class AddPeopleNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = invitePeoplePresenter.present() 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 { interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents data object SendInvites : InvitePeopleEvents
data object CloseSearch : InvitePeopleEvents
} }

View file

@ -8,5 +8,7 @@
package io.element.android.features.invitepeople.api package io.element.android.features.invitepeople.api
interface InvitePeopleState { interface InvitePeopleState {
val canInvite: Boolean
val isSearchActive: Boolean
val eventSink: (InvitePeopleEvents) -> Unit 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.androidutils)
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.services.apperror.api)
api(projects.features.invitepeople.api) api(projects.features.invitepeople.api)
testImplementation(libs.test.junit) 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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember 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.RoomMembershipState
import io.element.android.libraries.matrix.api.room.filterMembers import io.element.android.libraries.matrix.api.room.filterMembers
import io.element.android.libraries.matrix.api.user.MatrixUser 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.libraries.usersearch.api.UserRepository
import io.element.android.services.apperror.api.AppErrorStateService
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DefaultInvitePeoplePresenter @AssistedInject constructor( class DefaultInvitePeoplePresenter @AssistedInject constructor(
@Assisted private val room: JoinedRoom, @Assisted private val room: JoinedRoom,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
private val appErrorStateService: AppErrorStateService,
) : InvitePeoplePresenter { ) : InvitePeoplePresenter {
@AssistedFactory @AssistedFactory
@ -97,12 +104,30 @@ class DefaultInvitePeoplePresenter @AssistedInject constructor(
searchResults.toggleUser(it.user) searchResults.toggleUser(it.user)
} }
is InvitePeopleEvents.SendInvites -> { 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") @JvmName("toggleUserInSelectedUsers")
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) { private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) { value = if (value.contains(user)) {

View file

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

View file

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

View file

@ -7,10 +7,10 @@
package io.element.android.features.invitepeople.impl package io.element.android.features.invitepeople.impl
import androidx.activity.OnBackPressedDispatcher
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bumble.appyx.core.plugin.BackPressHandler
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.async.AsyncLoading 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.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.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.HorizontalDivider 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.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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.Text
@ -46,87 +46,46 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun InvitePeopleView( fun InvitePeopleView(
state: DefaultInvitePeopleState, state: DefaultInvitePeopleState,
onBackClick: () -> Unit,
onSubmitClick: (List<MatrixUser>) -> Unit,
modifier: Modifier = Modifier, 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) { Column(
SelectedUsersRowList( modifier = modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp),
selectedUsers = state.selectedUsers, ) {
autoScroll = true, InvitePeopleSearchBar(
onUserRemove = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) }, modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp), 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RoomInviteMembersTopBar( private fun InvitePeopleSearchBar(
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(
query: String, query: String,
state: SearchBarResultState<ImmutableList<InvitableUser>>, state: SearchBarResultState<ImmutableList<InvitableUser>>,
showLoader: Boolean, showLoader: Boolean,
@ -219,9 +178,5 @@ private fun RoomInviteMembersSearchBar(
@Composable @Composable
internal fun RoomInviteMembersViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) = internal fun RoomInviteMembersViewPreview(@PreviewParameter(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) =
ElementPreview { ElementPreview {
InvitePeopleView( InvitePeopleView(state = state)
state = state,
onBackClick = {},
onSubmitClick = {},
)
} }

View file

@ -56,6 +56,7 @@ dependencies {
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.roommembermoderation.api) implementation(projects.features.roommembermoderation.api)
implementation(projects.features.changeroommemberroles.api) implementation(projects.features.changeroommemberroles.api)
implementation(projects.features.invitepeople.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@ -18,27 +17,22 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode 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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.JoinedRoom 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.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) @ContributesNode(RoomScope::class)
class RoomInviteMembersNode @AssistedInject constructor( class RoomInviteMembersNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
coroutineDispatchers: CoroutineDispatchers,
private val room: JoinedRoom, private val room: JoinedRoom,
private val presenter: RoomInviteMembersPresenter,
private val appErrorStateService: AppErrorStateService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val invitePeopleRenderer: InvitePeopleRenderer,
private val invitePeoplePresenterFactory: InvitePeoplePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io)
init { init {
lifecycle.subscribe( lifecycle.subscribe(
@ -48,31 +42,17 @@ class RoomInviteMembersNode @AssistedInject constructor(
) )
} }
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(room)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = invitePeoplePresenter.present()
val context = LocalContext.current.applicationContext
RoomInviteMembersView( RoomInviteMembersView(
state = state, state = state,
modifier = modifier, modifier = modifier,
invitePeopleView = { invitePeopleRenderer.Render(state, Modifier) },
onBackClick = { navigateUp() }, onBackClick = { navigateUp() },
onSubmitClick = { users -> onSubmitClick = { navigateUp() }
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),
)
}
}
}
) )
} }
} }

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 package io.element.android.features.roomdetails.impl.invite
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.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.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.components.button.BackButton
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.HorizontalDivider
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.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.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun RoomInviteMembersView( fun RoomInviteMembersView(
state: RoomInviteMembersState, state: InvitePeopleState,
invitePeopleView: @Composable () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onSubmitClick: (List<MatrixUser>) -> Unit, onSubmitClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
@ -57,45 +45,28 @@ fun RoomInviteMembersView(
RoomInviteMembersTopBar( RoomInviteMembersTopBar(
onBackClick = { onBackClick = {
if (state.isSearchActive) { if (state.isSearchActive) {
state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(false)) state.eventSink(InvitePeopleEvents.CloseSearch)
} else { } else {
onBackClick() onBackClick()
} }
}, },
onSubmitClick = { onSubmitClick(state.selectedUsers) }, onSubmitClick = {
state.eventSink(InvitePeopleEvents.SendInvites)
onSubmitClick()
},
canSend = state.canInvite, canSend = state.canInvite,
) )
} }
) { padding -> ) { padding ->
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
RoomInviteMembersSearchBar( invitePeopleView()
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),
)
}
} }
} }
} }
@ -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 @PreviewsDayNight
@Composable @Composable
internal fun RoomInviteMembersViewPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview { internal fun RoomInviteMembersViewPreview(@PreviewParameter(InvitePeopleStateProvider::class) state: InvitePeopleState) = ElementPreview {
RoomInviteMembersView( RoomInviteMembersView(
state = state, state = state,
invitePeopleView = {},
onBackClick = {}, onBackClick = {},
onSubmitClick = {}, onSubmitClick = {},
) )

View file

@ -7,10 +7,14 @@
package io.element.android.services.apperror.api package io.element.android.services.apperror.api
import androidx.annotation.StringRes
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface AppErrorStateService { interface AppErrorStateService {
val appErrorStateFlow: StateFlow<AppErrorState> val appErrorStateFlow: StateFlow<AppErrorState>
fun showError(title: String, body: String) 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 package io.element.android.services.apperror.impl
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope 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.libraries.di.SingleIn
import io.element.android.services.apperror.api.AppErrorState import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService import io.element.android.services.apperror.api.AppErrorStateService
@ -18,7 +20,9 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@SingleIn(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) private val currentAppErrorState = MutableStateFlow<AppErrorState>(AppErrorState.NoError)
override val appErrorStateFlow: StateFlow<AppErrorState> = currentAppErrorState 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)
}
} }