refactor (start chat) : remove useless code

This commit is contained in:
ganfra
2025-08-08 17:42:06 +02:00
committed by Benoit Marty
parent 50073389c4
commit 98343e0531
24 changed files with 17 additions and 1556 deletions

View File

@@ -11,9 +11,15 @@ import android.net.Uri
import app.cash.turbine.TurbineTestContext
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.invitepeople.impl.CreateRoomConfig
import io.element.android.features.invitepeople.impl.CreateRoomDataStore
import io.element.android.features.invitepeople.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomEvents
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomPresenter
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomState
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfigStore
import io.element.android.features.createroom.impl.configureroom.RoomAccess
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -28,7 +34,6 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.mediapickers.api.PickerProvider
@@ -48,8 +53,6 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -67,7 +70,7 @@ private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class)
class ConfigureBaseRoomPresenterTest {
class ConfigureRoomPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -124,12 +127,11 @@ class ConfigureBaseRoomPresenterTest {
@Test
fun `present - state is updated when fields are changed`() = runTest {
val userListDataStore = UserListDataStore()
val pickerProvider = FakePickerProvider()
val permissionsPresenter = FakePermissionsPresenter()
val roomAliasHelper = FakeRoomAliasHelper()
val presenter = createConfigureRoomPresenter(
createRoomDataStore = CreateRoomDataStore(userListDataStore, roomAliasHelper),
dataStore = CreateRoomConfigStore(roomAliasHelper),
pickerProvider = pickerProvider,
permissionsPresenter = permissionsPresenter,
)
@@ -138,19 +140,10 @@ class ConfigureBaseRoomPresenterTest {
var expectedConfig = CreateRoomConfig()
assertThat(initialState.config).isEqualTo(expectedConfig)
// Select User
val selectedUser1 = aMatrixUser()
val selectedUser2 = aMatrixUser("@id_of_bob:server.org", "Bob")
userListDataStore.selectUser(selectedUser1)
skipItems(1)
userListDataStore.selectUser(selectedUser2)
var newState = awaitItem()
expectedConfig = expectedConfig.copy(invites = persistentListOf(selectedUser1, selectedUser2))
assertThat(newState.config).isEqualTo(expectedConfig)
// Room name
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
newState = awaitItem()
var newState = awaitItem()
expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(expectedConfig)
@@ -206,12 +199,6 @@ class ConfigureBaseRoomPresenterTest {
)
)
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove user
newState.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(selectedUser1))
newState = awaitItem()
expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
assertThat(newState.config).isEqualTo(expectedConfig)
}
}
@@ -263,16 +250,16 @@ class ConfigureBaseRoomPresenterTest {
val matrixClient = createMatrixClient()
val analyticsService = FakeAnalyticsService()
val mediaPreProcessor = FakeMediaPreProcessor()
val createRoomDataStore = CreateRoomDataStore(UserListDataStore(), FakeRoomAliasHelper())
val dataStore = CreateRoomConfigStore( FakeRoomAliasHelper())
val presenter = createConfigureRoomPresenter(
createRoomDataStore = createRoomDataStore,
dataStore = dataStore,
mediaPreProcessor = mediaPreProcessor,
matrixClient = matrixClient,
analyticsService = analyticsService
)
presenter.test {
val initialState = initialState()
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
dataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
skipItems(1)
mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
@@ -405,7 +392,7 @@ class ConfigureBaseRoomPresenterTest {
private fun createConfigureRoomPresenter(
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore(), roomAliasHelper),
dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper),
matrixClient: MatrixClient = createMatrixClient(),
pickerProvider: PickerProvider = FakePickerProvider(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
@@ -414,7 +401,7 @@ class ConfigureBaseRoomPresenterTest {
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
dataStore = dataStore,
matrixClient = matrixClient,
mediaPickerProvider = pickerProvider,
mediaPreProcessor = mediaPreProcessor,

View File

@@ -1,22 +0,0 @@
/*
* 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.startchat.impl
import android.net.Uri
import io.element.android.features.startchat.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class CreateRoomConfig(
val roomName: String? = null,
val topic: String? = null,
val avatarUri: Uri? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(),
val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private,
)

View File

@@ -1,134 +0,0 @@
/*
* 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.startchat.impl
import android.net.Uri
import io.element.android.features.startchat.impl.configureroom.RoomAccess
import io.element.android.features.startchat.impl.configureroom.RoomAccessItem
import io.element.android.features.startchat.impl.configureroom.RoomAddress
import io.element.android.features.startchat.impl.configureroom.RoomVisibilityItem
import io.element.android.features.startchat.impl.configureroom.RoomVisibilityState
import io.element.android.features.startchat.impl.di.CreateRoomScope
import io.element.android.features.startchat.impl.userlist.UserListDataStore
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate
import java.io.File
import javax.inject.Inject
@SingleIn(CreateRoomScope::class)
class CreateRoomDataStore @Inject constructor(
val selectedUserListDataStore: UserListDataStore,
private val roomAliasHelper: RoomAliasHelper,
) {
private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig())
private var cachedAvatarUri: Uri? = null
set(value) {
field?.path?.let { File(it) }?.safeDelete()
field = value
}
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
selectedUserListDataStore.selectedUsers,
createRoomConfigFlow,
) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList())
}
fun setRoomName(roomName: String) {
createRoomConfigFlow.getAndUpdate { config ->
val newVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
val roomAddress = config.roomVisibility.roomAddress
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName)
config.roomVisibility.copy(
roomAddress = RoomAddress.AutoFilled(roomAliasName),
)
} else {
config.roomVisibility
}
}
else -> config.roomVisibility
}
config.copy(
roomName = roomName.takeIf { it.isNotEmpty() },
roomVisibility = newVisibility,
)
}
}
fun setTopic(topic: String) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(topic = topic.takeIf { it.isNotEmpty() })
}
}
fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
cachedAvatarUri = uri.takeIf { cached }
createRoomConfigFlow.getAndUpdate { config ->
config.copy(avatarUri = uri)
}
}
fun setRoomVisibility(visibility: RoomVisibilityItem) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (visibility) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private
RoomVisibilityItem.Public -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasName),
roomAccess = RoomAccess.Anyone,
)
}
}
)
}
}
fun setRoomAddress(address: String) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
val sanitizedAddress = address.lowercase()
config.roomVisibility.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
}
else -> config.roomVisibility
}
)
}
}
fun setRoomAccess(access: RoomAccessItem) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
when (access) {
RoomAccessItem.Anyone -> config.roomVisibility.copy(roomAccess = RoomAccess.Anyone)
RoomAccessItem.AskToJoin -> config.roomVisibility.copy(roomAccess = RoomAccess.Knocking)
}
}
else -> config.roomVisibility
}
)
}
}
fun clearCachedData() {
cachedAvatarUri = null
}
}

View File

@@ -1,83 +0,0 @@
/*
* 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.startchat.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.core.plugin.plugins
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.startchat.StartChatNavigator
import io.element.android.features.startchat.impl.addpeople.AddPeopleNode
import io.element.android.features.startchat.impl.configureroom.ConfigureRoomNode
import io.element.android.features.startchat.impl.di.CreateRoomComponent
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class CreateRoomFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : DaggerComponentOwner,
BaseFlowNode<CreateRoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.ConfigureRoom,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
private val component by lazy {
parent!!.bindings<CreateRoomComponent.ParentBindings>().createRoomComponentBuilder().build()
}
private val navigator = plugins<StartChatNavigator>().first()
override val daggerComponent: Any
get() = component
sealed interface NavTarget : Parcelable {
@Parcelize
data object AddPeople : NavTarget
@Parcelize
data object ConfigureRoom : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.AddPeople -> {
val callback = object : AddPeopleNode.Callback {
override fun onContinue() {
backstack.push(NavTarget.ConfigureRoom)
}
}
createNode<AddPeopleNode>(buildContext = buildContext, plugins = listOf(callback))
}
NavTarget.ConfigureRoom -> {
createNode<ConfigureRoomNode>(buildContext = buildContext, plugins = listOf(navigator))
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}

View File

@@ -1,45 +0,0 @@
/*
* 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.startchat.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.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.startchat.impl.di.CreateRoomScope
@ContributesNode(CreateRoomScope::class)
class AddPeopleNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AddPeoplePresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onContinue()
}
private fun onContinue() {
plugins<Callback>().forEach { it.onContinue() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
AddPeopleView(
state = state,
modifier = modifier,
onBackClick = this::navigateUp,
onSkipClick = this::onContinue,
)
}
}

View File

@@ -1,37 +0,0 @@
/*
* 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.startchat.impl.addpeople
import androidx.compose.runtime.Composable
import io.element.android.features.startchat.impl.CreateRoomDataStore
import io.element.android.features.startchat.impl.userlist.SelectionMode
import io.element.android.features.startchat.impl.userlist.UserListPresenter
import io.element.android.features.startchat.impl.userlist.UserListPresenterArgs
import io.element.android.features.startchat.impl.userlist.UserListState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
import javax.inject.Inject
class AddPeoplePresenter @Inject constructor(
userListPresenterFactory: UserListPresenter.Factory,
userRepository: UserRepository,
dataStore: CreateRoomDataStore,
) : Presenter<UserListState> {
private val userListPresenter = userListPresenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Multiple,
),
userRepository,
dataStore.selectedUserListDataStore,
)
@Composable
override fun present(): UserListState {
return userListPresenter.present()
}
}

View File

@@ -1,46 +0,0 @@
/*
* 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.startchat.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.startchat.impl.userlist.SelectionMode
import io.element.android.features.startchat.impl.userlist.UserListState
import io.element.android.features.startchat.impl.userlist.aRecentDirectRoomList
import io.element.android.features.startchat.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.toImmutableList
open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListState> {
override val values: Sequence<UserListState>
get() = sequenceOf(
aUserListState(),
aUserListState(
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = false,
selectionMode = SelectionMode.Multiple,
),
aUserListState(
searchResults = SearchBarResultState.Results(
aMatrixUserList()
.mapIndexed { index, matrixUser ->
UserSearchResult(matrixUser, index % 2 == 0)
}
.toImmutableList()
),
selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,
),
aUserListState(
recentDirectRooms = aRecentDirectRoomList(),
),
)
}

View File

@@ -1,94 +0,0 @@
/*
* 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.startchat.impl.addpeople
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
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.tooling.preview.PreviewParameter
import io.element.android.features.startchat.impl.R
import io.element.android.features.startchat.impl.components.UserListView
import io.element.android.features.startchat.impl.userlist.UserListEvents
import io.element.android.features.startchat.impl.userlist.UserListState
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.Scaffold
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: UserListState,
onBackClick: () -> Unit,
onSkipClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
AddPeopleViewTopBar(
hasSelectedUsers = state.selectedUsers.isNotEmpty(),
onBackClick = {
if (state.isSearchActive) {
state.eventSink(UserListEvents.OnSearchActiveChanged(false))
} else {
onBackClick()
}
},
onNextClick = onSkipClick,
)
}
) { padding ->
UserListView(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding),
state = state,
showBackButton = false,
onSelectUser = {},
onDeselectUser = {},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddPeopleViewTopBar(
hasSelectedUsers: Boolean,
onBackClick: () -> Unit,
onNextClick: () -> Unit,
) {
TopAppBar(
titleStr = stringResource(id = R.string.screen_create_room_add_people_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip
TextButton(
text = stringResource(id = textActionResId),
onClick = onNextClick,
)
}
)
}
@PreviewsDayNight
@Composable
internal fun AddPeopleViewPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = ElementPreview {
AddPeopleView(
state = state,
onBackClick = {},
onSkipClick = {},
)
}

View File

@@ -1,23 +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.startchat.impl.configureroom
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents
data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
data class RemoveUserFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents
}

View File

@@ -1,55 +0,0 @@
/*
* 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.startchat.impl.configureroom
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.features.startchat.StartChatNavigator
import io.element.android.features.startchat.impl.di.CreateRoomScope
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(CreateRoomScope::class)
class ConfigureRoomNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: ConfigureRoomPresenter,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<StartChatNavigator>().first()
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreateRoom))
}
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ConfigureRoomView(
state = state,
modifier = modifier,
onBackClick = this::navigateUp,
onCreateRoomSuccess = {
navigator.onOpenRoom(roomIdOrAlias = it.toRoomIdOrAlias(), serverNames = emptyList())
},
)
}
}

View File

@@ -1,209 +0,0 @@
/*
* 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.startchat.impl.configureroom
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.startchat.impl.CreateRoomConfig
import io.element.android.features.startchat.impl.CreateRoomDataStore
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.jvm.optionals.getOrDefault
class ConfigureRoomPresenter @Inject constructor(
private val dataStore: CreateRoomDataStore,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor,
private val analyticsService: AnalyticsService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val featureFlagService: FeatureFlagService,
private val roomAliasHelper: RoomAliasHelper,
) : Presenter<ConfigureRoomState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@Composable
override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(initial = false)
val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
)
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri) }
)
val avatarActions by remember(createRoomConfig.avatarUri) {
derivedStateOf {
listOfNotNull(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove.takeIf { createRoomConfig.avatarUri != null },
).toImmutableList()
}
}
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
cameraPhotoPicker.launch()
}
}
RoomAddressValidityEffect(
client = matrixClient,
roomAliasHelper = roomAliasHelper,
newRoomAddress = createRoomConfig.roomVisibility.roomAddress().getOrDefault(""),
knownRoomAddress = null,
) { newRoomAddressValidity ->
roomAddressValidity.value = newRoomAddressValidity
}
val localCoroutineScope = rememberCoroutineScope()
val createRoomAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
fun createRoom(config: CreateRoomConfig) {
createRoomAction.value = AsyncAction.Uninitialized
localCoroutineScope.createRoom(config, createRoomAction)
}
fun handleEvents(event: ConfigureRoomEvents) {
when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem)
is ConfigureRoomEvents.RemoveUserFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
}
ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = AsyncAction.Uninitialized
}
}
return ConfigureRoomState(
isKnockFeatureEnabled = isKnockFeatureEnabled,
config = createRoomConfig,
avatarActions = avatarActions,
createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity.value,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.createRoom(
config: CreateRoomConfig,
createRoomAction: MutableState<AsyncAction<RoomId>>
) = launch {
suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
val params = if (config.roomVisibility is RoomVisibilityState.Public) {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = false,
isDirect = false,
visibility = RoomVisibility.Public,
joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(),
preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
roomAliasName = config.roomVisibility.roomAddress()
)
} else {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
isDirect = false,
visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
)
}
matrixClient.createRoom(params)
.onFailure { failure ->
Timber.e(failure, "Failed to create room")
}
.onSuccess {
dataStore.clearCachedData()
analyticsService.capture(CreatedRoom(isDM = false))
}
.getOrThrow()
}.runCatchingUpdatingState(createRoomAction)
}
private suspend fun uploadAvatar(avatarUri: Uri): String {
val preprocessed = mediaPreProcessor.process(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
compressIfPossible = false,
).getOrThrow()
val byteArray = preprocessed.file.readBytes()
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
}
}

View File

@@ -1,14 +0,0 @@
/*
* 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.startchat.impl.configureroom
import io.element.android.libraries.matrix.api.user.MatrixUser
data class ConfigureRoomPresenterArgs(
val selectedUsers: List<MatrixUser>,
)

View File

@@ -1,30 +0,0 @@
/*
* 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.startchat.impl.configureroom
import io.element.android.features.startchat.impl.CreateRoomConfig
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState(
val isKnockFeatureEnabled: Boolean,
val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: AsyncAction<RoomId>,
val cameraPermissionState: PermissionsState,
val roomAddressValidity: RoomAddressValidity,
val homeserverName: String,
val eventSink: (ConfigureRoomEvents) -> Unit
) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true &&
(config.roomVisibility is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid)
}

View File

@@ -1,102 +0,0 @@
/*
* 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.startchat.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.startchat.impl.CreateRoomConfig
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
override val values: Sequence<ConfigureRoomState>
get() = sequenceOf(
aConfigureRoomState(),
aConfigureRoomState(
isKnockFeatureEnabled = false,
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
),
),
),
aConfigureRoomState(
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
),
),
),
aConfigureRoomState(
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
),
),
roomAddressValidity = RoomAddressValidity.NotAvailable,
),
aConfigureRoomState(
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
),
),
roomAddressValidity = RoomAddressValidity.InvalidSymbols,
),
aConfigureRoomState(
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
),
),
roomAddressValidity = RoomAddressValidity.Valid,
),
)
}
fun aConfigureRoomState(
config: CreateRoomConfig = CreateRoomConfig(),
isKnockFeatureEnabled: Boolean = true,
avatarActions: List<AvatarAction> = emptyList(),
createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
homeserverName: String = "matrix.org",
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid,
eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState(
config = config,
isKnockFeatureEnabled = isKnockFeatureEnabled,
avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity,
eventSink = eventSink,
)

View File

@@ -1,339 +0,0 @@
/*
* 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.startchat.impl.configureroom
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.startchat.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
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.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.matrix.ui.room.address.RoomAddressField
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ConfigureRoomView(
state: ConfigureRoomState,
onBackClick: () -> Unit,
onCreateRoomSuccess: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
fun onAvatarClick() {
focusManager.clearFocus()
isAvatarActionsSheetVisible.value = true
}
Scaffold(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
ConfigureRoomToolbar(
isNextActionEnabled = state.isValid,
onBackClick = onBackClick,
onNextClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.CreateRoom)
},
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
RoomNameWithAvatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
onAvatarClick = ::onAvatarClick,
onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
if (state.config.invites.isNotEmpty()) {
SelectedUsersRowList(
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,
onUserRemove = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(it))
},
)
}
RoomVisibilityOptions(
selected = when (state.config.roomVisibility) {
is RoomVisibilityState.Private -> RoomVisibilityItem.Private
is RoomVisibilityState.Public -> RoomVisibilityItem.Public
},
onOptionClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it))
},
)
if (state.config.roomVisibility is RoomVisibilityState.Public && state.isKnockFeatureEnabled) {
RoomAccessOptions(
selected = when (state.config.roomVisibility.roomAccess) {
RoomAccess.Anyone -> RoomAccessItem.Anyone
RoomAccess.Knocking -> RoomAccessItem.AskToJoin
},
onOptionClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomAccessChanged(it))
},
)
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.roomVisibility.roomAddress.value,
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
label = stringResource(R.string.screen_create_room_room_address_section_title),
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
)
Spacer(Modifier)
}
}
}
AvatarActionBottomSheet(
actions = state.avatarActions,
isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
onSelectAction = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
)
AsyncActionView(
async = state.createRoomAction,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
progressText = stringResource(CommonStrings.common_creating_room),
)
},
onSuccess = { onCreateRoomSuccess(it) },
errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) },
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
)
PermissionsView(
state = state.cameraPermissionState,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConfigureRoomToolbar(
isNextActionEnabled: Boolean,
onBackClick: () -> Unit,
onNextClick: () -> Unit,
) {
TopAppBar(
titleStr = stringResource(R.string.screen_create_room_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
TextButton(
text = stringResource(CommonStrings.action_create),
enabled = isNextActionEnabled,
onClick = onNextClick,
)
}
)
}
@Composable
private fun RoomNameWithAvatar(
avatarUri: Uri?,
roomName: String,
onAvatarClick: () -> Unit,
onChangeRoomName: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UnsavedAvatar(
avatarUri = avatarUri,
avatarSize = AvatarSize.EditRoomDetails,
avatarType = AvatarType.Room(),
modifier = Modifier.clickable(onClick = onAvatarClick),
)
TextField(
label = stringResource(R.string.screen_create_room_room_name_label),
value = roomName,
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
singleLine = true,
onValueChange = onChangeRoomName,
)
}
}
@Composable
private fun RoomTopic(
topic: String,
onTopicChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TextField(
modifier = modifier,
label = stringResource(R.string.screen_create_room_topic_label),
value = topic,
onValueChange = onTopicChange,
maxLines = 3,
supportingText = stringResource(CommonStrings.common_topic_placeholder),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),
)
}
@Composable
private fun ConfigureRoomOptions(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.selectableGroup()
) {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp),
)
content()
}
}
@Composable
private fun RoomVisibilityOptions(
selected: RoomVisibilityItem,
onOptionClick: (RoomVisibilityItem) -> Unit,
modifier: Modifier = Modifier,
) {
ConfigureRoomOptions(
title = stringResource(R.string.screen_create_room_room_visibility_section_title),
modifier = modifier,
) {
RoomVisibilityItem.entries.forEach { item ->
val isSelected = item == selected
ListItem(
leadingContent = ListItemContent.Custom {
RoundedIconAtom(
size = RoundedIconAtomSize.Big,
resourceId = item.icon,
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
)
},
headlineContent = { Text(text = stringResource(item.title)) },
supportingContent = { Text(text = stringResource(item.description)) },
trailingContent = ListItemContent.RadioButton(selected = isSelected),
onClick = { onOptionClick(item) },
)
}
}
}
@Composable
private fun RoomAccessOptions(
selected: RoomAccessItem,
onOptionClick: (RoomAccessItem) -> Unit,
modifier: Modifier = Modifier,
) {
ConfigureRoomOptions(
title = stringResource(R.string.screen_create_room_room_access_section_header),
modifier = modifier,
) {
RoomAccessItem.entries.forEach { item ->
ListItem(
headlineContent = { Text(text = stringResource(item.title)) },
supportingContent = { Text(text = stringResource(item.description)) },
trailingContent = ListItemContent.RadioButton(selected = item == selected),
onClick = { onOptionClick(item) },
)
}
}
}
@PreviewWithLargeHeight
@Composable
internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
ElementPreviewLight { ContentToPreview(state) }
@PreviewWithLargeHeight
@Composable
internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
ElementPreviewDark { ContentToPreview(state) }
@ExcludeFromCoverage
@Composable
private fun ContentToPreview(state: ConfigureRoomState) {
ConfigureRoomView(
state = state,
onBackClick = {},
onCreateRoomSuccess = {},
)
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright 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.startchat.impl.configureroom
import io.element.android.libraries.matrix.api.room.join.JoinRule
enum class RoomAccess {
Anyone,
Knocking
}
fun RoomAccess.toJoinRule(): JoinRule? {
return when (this) {
RoomAccess.Anyone -> null
RoomAccess.Knocking -> JoinRule.Knock
}
}

View File

@@ -1,25 +0,0 @@
/*
* 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.startchat.impl.configureroom
import androidx.annotation.StringRes
import io.element.android.features.startchat.impl.R
enum class RoomAccessItem(
@StringRes val title: Int,
@StringRes val description: Int
) {
Anyone(
title = R.string.screen_create_room_room_access_section_anyone_option_title,
description = R.string.screen_create_room_room_access_section_anyone_option_description,
),
AskToJoin(
title = R.string.screen_create_room_room_access_section_knocking_option_title,
description = R.string.screen_create_room_room_access_section_knocking_option_description,
),
}

View File

@@ -1,13 +0,0 @@
/*
* Copyright 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.startchat.impl.configureroom
sealed class RoomAddress(open val value: String) {
data class AutoFilled(override val value: String) : RoomAddress(value)
data class Edited(override val value: String) : RoomAddress(value)
}

View File

@@ -1,30 +0,0 @@
/*
* 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.startchat.impl.configureroom
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import io.element.android.features.startchat.impl.R
import io.element.android.libraries.designsystem.icons.CompoundDrawables
enum class RoomVisibilityItem(
@DrawableRes val icon: Int,
@StringRes val title: Int,
@StringRes val description: Int
) {
Private(
icon = CompoundDrawables.ic_compound_lock,
title = R.string.screen_create_room_private_option_title,
description = R.string.screen_create_room_private_option_description,
),
Public(
icon = CompoundDrawables.ic_compound_public,
title = R.string.screen_create_room_public_option_title,
description = R.string.screen_create_room_public_option_description,
)
}

View File

@@ -1,26 +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.startchat.impl.configureroom
import java.util.Optional
sealed interface RoomVisibilityState {
data object Private : RoomVisibilityState
data class Public(
val roomAddress: RoomAddress,
val roomAccess: RoomAccess,
) : RoomVisibilityState
fun roomAddress(): Optional<String> {
return when (this) {
is Private -> Optional.empty()
is Public -> Optional.of(roomAddress.value)
}
}
}

View File

@@ -1,28 +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.startchat.impl.di
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.MergeSubcomponent
import io.element.android.libraries.architecture.NodeFactoriesBindings
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
@SingleIn(CreateRoomScope::class)
@MergeSubcomponent(CreateRoomScope::class)
interface CreateRoomComponent : NodeFactoriesBindings {
@MergeSubcomponent.Builder
interface Builder {
fun build(): CreateRoomComponent
}
@ContributesTo(SessionScope::class)
interface ParentBindings {
fun createRoomComponentBuilder(): Builder
}
}

View File

@@ -1,10 +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.startchat.impl.di
abstract class CreateRoomScope private constructor()

View File

@@ -1,50 +0,0 @@
/*
* 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.startchat.impl.addpeople
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invitepeople.impl.CreateRoomDataStore
import io.element.android.features.startchat.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.invitepeople.impl.userlist.UserListDataStore
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class AddPeoplePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private lateinit var presenter: AddPeoplePresenter
@Before
fun setup() {
presenter = AddPeoplePresenter(
FakeUserListPresenterFactory(),
FakeUserRepository(),
CreateRoomDataStore(UserListDataStore(), FakeRoomAliasHelper())
)
}
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// TODO This doesn't actually test anything...
val initialState = awaitItem()
assertThat(initialState)
}
}
}

View File

@@ -1,89 +0,0 @@
/*
* Copyright 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.startchat.impl.addpeople
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.invitepeople.impl.userlist.UserListEvents
import io.element.android.features.invitepeople.impl.userlist.UserListState
import io.element.android.features.invitepeople.impl.userlist.aUserListState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AddPeopleViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
ensureCalledOnce {
rule.setAddPeopleView(
aUserListState(
eventSink = eventsRecorder,
),
onBackClick = it
)
rule.pressBack()
}
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
}
@Test
fun `clicking on back during search emits the expected Event`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
rule.setAddPeopleView(
aUserListState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
rule.pressBack()
eventsRecorder.assertSingle(UserListEvents.OnSearchActiveChanged(false))
}
@Test
fun `clicking on skip invokes the expected callback`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
ensureCalledOnce {
rule.setAddPeopleView(
aUserListState(
eventSink = eventsRecorder,
),
onNextClick = it
)
rule.clickOn(CommonStrings.action_skip)
}
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddPeopleView(
state: UserListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onNextClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
AddPeopleView(
state = state,
onBackClick = onBackClick,
onSkipClick = onNextClick,
)
}
}