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 {
implementation(projects.libraries.core)
@@ -41,6 +41,7 @@ dependencies {
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.invitepeople.api)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)

View File

@@ -8,25 +8,32 @@
package io.element.android.features.createroom.impl
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.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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
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.createNode
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 kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class CreateRoomFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val client: MatrixClient,
) : BaseFlowNode<CreateRoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.ConfigureRoom,
@@ -35,13 +42,30 @@ class CreateRoomFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins
) {
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.ConfigureRoom -> createNode<ConfigureRoomNode>(buildContext)
is NavTarget.AddPeople -> createNode<AddPeopleNode>(buildContext)
NavTarget.ConfigureRoom -> {
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 {
@Parcelize
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.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultCreateRoomEntryPoint @Inject constructor(): CreateRoomEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext)

View File

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

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}
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
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 {
@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
interface InvitePeopleState
interface InvitePeopleState {
val eventSink: (InvitePeopleEvents) -> Unit
}

View File

@@ -1,4 +1,3 @@
import extension.ComponentMergingStrategy
import extension.setupAnvil
/*
@@ -23,7 +22,7 @@ android {
}
}
setupAnvil(componentMergingStrategy = ComponentMergingStrategy.KSP)
setupAnvil()
dependencies {
implementation(projects.libraries.core)
@@ -33,14 +32,8 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
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.services.analytics.api)
implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
api(projects.features.invitepeople.api)
testImplementation(libs.test.junit)
@@ -50,14 +43,8 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.services.analytics.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.features.startchat.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
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(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.createroom.api)
api(projects.features.startchat.api)
testImplementation(libs.test.junit)

View File

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