refactor (start chat) : create invite people module and start branching them

This commit is contained in:
ganfra 2025-08-08 17:20:32 +02:00 committed by Benoit Marty
parent 1170a44116
commit 50073389c4
21 changed files with 717 additions and 32 deletions

View file

@ -23,7 +23,7 @@ android {
} }
} }
setupAnvil(componentMergingStrategy = ComponentMergingStrategy.KSP) setupAnvil()
dependencies { dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
@ -41,6 +41,7 @@ dependencies {
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.features.invitepeople.api)
api(projects.features.createroom.api) api(projects.features.createroom.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -8,25 +8,32 @@
package io.element.android.features.createroom.impl package io.element.android.features.createroom.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable
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.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
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
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class CreateRoomFlowNode @AssistedInject constructor( class CreateRoomFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val client: MatrixClient,
) : BaseFlowNode<CreateRoomFlowNode.NavTarget>( ) : BaseFlowNode<CreateRoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.ConfigureRoom, initialElement = NavTarget.ConfigureRoom,
@ -35,13 +42,30 @@ class CreateRoomFlowNode @AssistedInject constructor(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
) { ) {
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.ConfigureRoom -> createNode<ConfigureRoomNode>(buildContext) NavTarget.ConfigureRoom -> {
is NavTarget.AddPeople -> createNode<AddPeopleNode>(buildContext) val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) {
backstack.push(NavTarget.AddPeople(roomId))
}
}
createNode<ConfigureRoomNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.AddPeople -> {
val joinedRoom = runBlocking { client.getJoinedRoom(navTarget.roomId) } ?: error("Room not found")
val inputs = AddPeopleNode.Inputs(joinedRoom)
createNode<AddPeopleNode>(buildContext, plugins = listOf(inputs))
}
} }
} }
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object ConfigureRoom : NavTarget data object ConfigureRoom : NavTarget

View file

@ -9,10 +9,13 @@ package io.element.android.features.createroom.impl
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.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultCreateRoomEntryPoint @Inject constructor(): CreateRoomEntryPoint { class DefaultCreateRoomEntryPoint @Inject constructor(): CreateRoomEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node { override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext) return parentNode.createNode<CreateRoomFlowNode>(buildContext)

View file

@ -7,11 +7,40 @@
package io.element.android.features.createroom.impl.addpeople package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable
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.AssistedInject
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.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
class AddPeopleNode( @ContributesNode(SessionScope::class)
buildContext: BuildContext, class AddPeopleNode @AssistedInject constructor(
plugins: List<Plugin>, @Assisted buildContext: BuildContext,
) : Node(buildContext, plugins = plugins) @Assisted plugins: List<Plugin>,
private val invitePeoplePresenterFactory: InvitePeoplePresenter.Factory,
private val invitePeopleRenderer: InvitePeopleRenderer,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val joinedRoom: JoinedRoom
): NodeInputs
private val joinedRoom = inputs<Inputs>().joinedRoom
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(joinedRoom)
@Composable
override fun View(modifier: Modifier) {
val state = invitePeoplePresenter.present()
invitePeopleRenderer.Render(state, Modifier)
}
}

View file

@ -13,11 +13,14 @@ 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
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted 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.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -28,6 +31,10 @@ class ConfigureRoomNode @AssistedInject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback: Plugin {
fun onCreateRoomSuccess(roomId: RoomId)
}
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onResume = { onResume = {
@ -36,6 +43,10 @@ class ConfigureRoomNode @AssistedInject constructor(
) )
} }
private fun onCreateRoomSuccess(roomId: RoomId){
plugins<Callback>().forEach { it.onCreateRoomSuccess(roomId) }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -43,9 +54,7 @@ class ConfigureRoomNode @AssistedInject constructor(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackClick = this::navigateUp, onBackClick = this::navigateUp,
onCreateRoomSuccess = { onCreateRoomSuccess = ::onCreateRoomSuccess,
},
) )
} }
} }

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {

View file

@ -0,0 +1,12 @@
/*
* 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
interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents
}

View file

@ -7,9 +7,13 @@
package io.element.android.features.invitepeople.api package io.element.android.features.invitepeople.api
import androidx.compose.runtime.Composable import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface InvitePeoplePresenter : Presenter<InvitePeopleState> {
interface Factory {
fun create(room: JoinedRoom): InvitePeoplePresenter
}
interface InvitePeoplePresenter {
@Composable
fun present(): InvitePeopleState
} }

View file

@ -12,5 +12,8 @@ import androidx.compose.ui.Modifier
interface InvitePeopleRenderer { interface InvitePeopleRenderer {
@Composable @Composable
fun Render(state: InvitePeopleState, modifier: Modifier) fun Render(
state: InvitePeopleState,
modifier: Modifier,
)
} }

View file

@ -7,4 +7,6 @@
package io.element.android.features.invitepeople.api package io.element.android.features.invitepeople.api
interface InvitePeopleState interface InvitePeopleState {
val eventSink: (InvitePeopleEvents) -> Unit
}

View file

@ -1,4 +1,3 @@
import extension.ComponentMergingStrategy
import extension.setupAnvil import extension.setupAnvil
/* /*
@ -23,7 +22,7 @@ android {
} }
} }
setupAnvil(componentMergingStrategy = ComponentMergingStrategy.KSP) setupAnvil()
dependencies { dependencies {
implementation(projects.libraries.core) implementation(projects.libraries.core)
@ -33,14 +32,8 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.deeplink)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
api(projects.features.invitepeople.api) api(projects.features.invitepeople.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -50,14 +43,8 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View file

@ -0,0 +1,17 @@
/*
* 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.invitepeople.impl
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface DefaultInvitePeopleEvents: InvitePeopleEvents {
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
data class UpdateSearchQuery(val query: String) : DefaultInvitePeopleEvents
data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents
}

View file

@ -0,0 +1,168 @@
/*
* 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.invitepeople.impl
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 com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncData
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.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.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
class DefaultInvitePeoplePresenter @AssistedInject constructor(
@Assisted private val room: JoinedRoom,
private val userRepository: UserRepository,
private val coroutineDispatchers: CoroutineDispatchers,
) : InvitePeoplePresenter {
@AssistedFactory
@ContributesBinding(SessionScope::class)
interface Factory : InvitePeoplePresenter.Factory {
override fun create(room: JoinedRoom): DefaultInvitePeoplePresenter
}
@Composable
override fun present(): InvitePeopleState {
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 DefaultInvitePeopleState(
canInvite = selectedUsers.value.isNotEmpty(),
selectedUsers = selectedUsers.value,
searchQuery = searchQuery,
isSearchActive = searchActive,
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,
eventSink = {
when (it) {
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> {
searchActive = it.active
searchQuery = ""
}
is DefaultInvitePeopleEvents.UpdateSearchQuery -> {
searchQuery = it.query
}
is DefaultInvitePeopleEvents.ToggleUser -> {
selectedUsers.toggleUser(it.user)
searchResults.toggleUser(it.user)
}
is InvitePeopleEvents.SendInvites -> {
}
}
}
)
}
@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 {
room.filterMembers("", coroutineDispatchers.io).toImmutableList()
}.runCatchingUpdatingState(roomMembers)
}
}

View file

@ -0,0 +1,33 @@
/*
* 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.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invitepeople.api.InvitePeopleRenderer
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.di.SessionScope
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultInvitePeopleRenderer @Inject constructor() : InvitePeopleRenderer {
@Composable
override fun Render(state: InvitePeopleState, modifier: Modifier) {
if (state is DefaultInvitePeopleState) {
InvitePeopleView(
state = state,
onBackClick = {},
onSubmitClick = {},
modifier = modifier
)
} else {
error("Unsupported state type: ${state::javaClass}")
}
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.impl
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
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 DefaultInvitePeopleState(
val canInvite: Boolean,
val searchQuery: String,
val showSearchLoader: Boolean,
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
override val eventSink: (InvitePeopleEvents) -> Unit
): InvitePeopleState

View file

@ -0,0 +1,89 @@
/*
* 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.invitepeople.impl
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 DefaultInvitePeopleStateProvider : PreviewParameterProvider<DefaultInvitePeopleState> {
override val values: Sequence<DefaultInvitePeopleState>
get() = sequenceOf(
aDefaultInvitePeopleState(),
aDefaultInvitePeopleState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query"),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()),
aDefaultInvitePeopleState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResultsFound()),
aDefaultInvitePeopleState(
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),
)
)
),
aDefaultInvitePeopleState(
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")),
)
)
),
aDefaultInvitePeopleState(
isSearchActive = true,
canInvite = true,
searchQuery = "@alice:server.org",
searchResults = SearchBarResultState.Results(
persistentListOf(
InvitableUser(aMatrixUser("@alice:server.org"), isUnresolved = true),
)
),
showSearchLoader = true,
),
)
}
private fun aDefaultInvitePeopleState(
canInvite: Boolean = false,
searchQuery: String = "",
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false,
showSearchLoader: Boolean = false,
): DefaultInvitePeopleState {
return DefaultInvitePeopleState(
canInvite = canInvite,
searchQuery = searchQuery,
searchResults = searchResults,
selectedUsers = selectedUsers,
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
eventSink = {},
)
}

View file

@ -0,0 +1,18 @@
/*
* 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.impl
import io.element.android.libraries.matrix.api.user.MatrixUser
data class InvitableUser(
val matrixUser: MatrixUser,
val isSelected: Boolean = false,
val isAlreadyJoined: Boolean = false,
val isAlreadyInvited: Boolean = false,
val isUnresolved: Boolean = false,
)

View file

@ -0,0 +1,227 @@
/*
* 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.invitepeople.impl
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
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.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 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),
)
}
}
}
}
@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(
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 -> "Already a member"
invitableUser.isAlreadyInvited -> "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(DefaultInvitePeopleStateProvider::class) state: DefaultInvitePeopleState) =
ElementPreview {
InvitePeopleView(
state = state,
onBackClick = {},
onSubmitClick = {},
)
}

View file

@ -41,6 +41,7 @@ dependencies {
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.features.createroom.api)
api(projects.features.startchat.api) api(projects.features.startchat.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -21,6 +21,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack
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
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.startchat.DefaultStartChatNavigator import io.element.android.features.startchat.DefaultStartChatNavigator
import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.features.startchat.impl.joinbyaddress.JoinRoomByAddressNode import io.element.android.features.startchat.impl.joinbyaddress.JoinRoomByAddressNode
@ -36,6 +37,7 @@ import kotlinx.parcelize.Parcelize
class StartChatFlowNode @AssistedInject constructor( class StartChatFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val createRoomEntryPoint: CreateRoomEntryPoint,
) : BaseFlowNode<StartChatFlowNode.NavTarget>( ) : BaseFlowNode<StartChatFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -72,7 +74,7 @@ class StartChatFlowNode @AssistedInject constructor(
createNode<StartChatNode>(buildContext = buildContext, plugins = listOf(navigator)) createNode<StartChatNode>(buildContext = buildContext, plugins = listOf(navigator))
} }
NavTarget.NewRoom -> { NavTarget.NewRoom -> {
createNode<CreateRoomFlowNode>(buildContext = buildContext, plugins = listOf(navigator)) createRoomEntryPoint.createNode(parentNode = this, buildContext = buildContext)
} }
NavTarget.JoinByAddress -> { NavTarget.JoinByAddress -> {
createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator)) createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator))

View file

@ -0,0 +1,32 @@
/*
* 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.libraries.matrix.api.room
import io.element.android.libraries.core.bool.orFalse
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
/**
* Method to filter members by userId or displayName.
* It does filter through the already known members, it doesn't perform additional requests.
*/
suspend fun BaseRoom.filterMembers(query: String, coroutineContext: CoroutineContext): List<RoomMember> = withContext(coroutineContext) {
val roomMembersState = 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
}