Merge branch 'develop' into renovate/lifecycle

This commit is contained in:
ganfra 2024-11-07 20:55:13 +01:00 committed by GitHub
commit c0c83a5376
314 changed files with 2281 additions and 1021 deletions

View file

@ -79,7 +79,7 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: elementx-apk-maestro name: elementx-apk-maestro
- uses: mobile-dev-inc/action-maestro-cloud@v1.9.2 - uses: mobile-dev-inc/action-maestro-cloud@v1.9.4
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}

View file

@ -8,7 +8,7 @@
# Element X Android # Element X Android
Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionalities. Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/).
The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx). The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
@ -71,7 +71,7 @@ We're doing this as a way to share code between platforms and while we've seen p
## Status ## Status
This project is in work in progress. The app does not cover yet all functionalities we expect. The list of supported features can be found in [this issue](https://github.com/element-hq/element-x-android/issues/911). This project is in an early rollout and migration phase.
## Contributing ## Contributing

View file

@ -48,9 +48,9 @@ android {
} else { } else {
"io.element.android.x" "io.element.android.x"
} }
targetSdk = Versions.targetSdk targetSdk = Versions.TARGET_SDK
versionCode = Versions.versionCode versionCode = Versions.VERSION_CODE
versionName = Versions.versionName versionName = Versions.VERSION_NAME
// Keep abiFilter for the universalApk // Keep abiFilter for the universalApk
ndk { ndk {

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.call.api
import io.element.android.libraries.matrix.api.core.RoomId
/**
* Value for the local current call.
*/
sealed interface CurrentCall {
data object None : CurrentCall
data class RoomCall(
val roomId: RoomId,
) : CurrentCall
data class ExternalUrl(
val url: String,
) : CurrentCall
}

View file

@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.call.api
import kotlinx.coroutines.flow.StateFlow
interface CurrentCallService {
/**
* The current call state flow, which will be updated when the active call changes.
* This value reflect the local state of the call. It is not updated if the user answers
* a call from another session.
*/
val currentCall: StateFlow<CurrentCall>
}

View file

@ -183,6 +183,7 @@ private fun WebView.setup(
allowFileAccess = true allowFileAccess = true
domStorageEnabled = true domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false mediaPlaybackRequiresUserGesture = false
@Suppress("DEPRECATION")
databaseEnabled = true databaseEnabled = true
loadsImagesAutomatically = true loadsImagesAutomatically = true
userAgentString = userAgent userAgentString = userAgent

View file

@ -12,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -82,6 +83,7 @@ class DefaultActiveCallManager @Inject constructor(
private val ringingCallNotificationCreator: RingingCallNotificationCreator, private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat, private val notificationManagerCompat: NotificationManagerCompat,
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
private val defaultCurrentCallService: DefaultCurrentCallService,
) : ActiveCallManager { ) : ActiveCallManager {
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
@ -89,6 +91,7 @@ class DefaultActiveCallManager @Inject constructor(
init { init {
observeRingingCall() observeRingingCall()
observeCurrentCall()
} }
override fun registerIncomingCall(notificationData: CallNotificationData) { override fun registerIncomingCall(notificationData: CallNotificationData) {
@ -209,6 +212,28 @@ class DefaultActiveCallManager @Inject constructor(
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
private fun observeCurrentCall() {
activeCall
.onEach { value ->
if (value == null) {
defaultCurrentCallService.onCallEnded()
} else {
when (value.callState) {
is CallState.Ringing -> {
// Nothing to do
}
is CallState.InCall -> {
when (val callType = value.callType) {
is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
}
}
}
}
}
.launchIn(coroutineScope)
}
} }
/** /**

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCurrentCallService @Inject constructor() : CurrentCallService {
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
fun onCallStarted(call: CurrentCall) {
currentCall.value = call
}
fun onCallEnded() {
currentCall.value = CurrentCall.None
}
}

View file

@ -15,6 +15,7 @@ import io.element.android.features.call.impl.notifications.RingingCallNotificati
import io.element.android.features.call.impl.utils.ActiveCall import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager import io.element.android.features.call.impl.utils.DefaultActiveCallManager
import io.element.android.features.call.impl.utils.DefaultCurrentCallService
import io.element.android.features.call.test.aCallNotificationData import io.element.android.features.call.test.aCallNotificationData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -299,5 +300,6 @@ class DefaultActiveCallManagerTest {
), ),
notificationManagerCompat = notificationManagerCompat, notificationManagerCompat = notificationManagerCompat,
matrixClientProvider = matrixClientProvider, matrixClientProvider = matrixClientProvider,
defaultCurrentCallService = DefaultCurrentCallService(),
) )
} }

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.call.test
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import kotlinx.coroutines.flow.MutableStateFlow
class FakeCurrentCallService(
override val currentCall: MutableStateFlow<CurrentCall> = MutableStateFlow(CurrentCall.None),
) : CurrentCallService

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
api(projects.features.createroom.api) api(projects.features.createroom.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -56,6 +57,7 @@ dependencies {
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test) testImplementation(projects.features.createroom.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

@ -8,7 +8,7 @@
package io.element.android.features.createroom.impl package io.element.android.features.createroom.impl
import android.net.Uri import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -18,5 +18,7 @@ data class CreateRoomConfig(
val topic: String? = null, val topic: String? = null,
val avatarUri: Uri? = null, val avatarUri: Uri? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(), val invites: ImmutableList<MatrixUser> = persistentListOf(),
val privacy: RoomPrivacy = RoomPrivacy.Private, val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private,
) ) {
val isValid = roomName.isNullOrEmpty().not() && roomVisibility.isValid()
}

View file

@ -8,7 +8,12 @@
package io.element.android.features.createroom.impl package io.element.android.features.createroom.impl
import android.net.Uri import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.configureroom.RoomAccess
import io.element.android.features.createroom.impl.configureroom.RoomAccessItem
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomAddressErrorState
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.features.createroom.impl.di.CreateRoomScope import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
@ -17,6 +22,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -31,28 +37,89 @@ class CreateRoomDataStore @Inject constructor(
field = value field = value
} }
fun getCreateRoomConfig(): Flow<CreateRoomConfig> = combine( val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
selectedUserListDataStore.selectedUsers(), selectedUserListDataStore.selectedUsers(),
createRoomConfigFlow, createRoomConfigFlow,
) { selectedUsers, config -> ) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList()) config.copy(invites = selectedUsers.toImmutableList())
} }
fun setRoomName(roomName: String?) { fun setRoomName(roomName: String) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(roomName = roomName?.takeIf { it.isNotEmpty() })) createRoomConfigFlow.getAndUpdate { config ->
/*
val newVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
val roomAddress = config.roomVisibility.roomAddress
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
config.roomVisibility.copy(
roomAddress = RoomAddress.AutoFilled(roomName),
)
} else {
config.roomVisibility
}
}
else -> config.roomVisibility
}
*/
config.copy(
roomName = roomName.takeIf { it.isNotEmpty() },
)
}
} }
fun setTopic(topic: String?) { fun setTopic(topic: String) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() })) createRoomConfigFlow.getAndUpdate { config ->
config.copy(topic = topic.takeIf { it.isNotEmpty() })
}
} }
fun setAvatarUri(uri: Uri?, cached: Boolean = false) { fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
cachedAvatarUri = uri.takeIf { cached } cachedAvatarUri = uri.takeIf { cached }
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri)) createRoomConfigFlow.getAndUpdate { config ->
config.copy(avatarUri = uri)
}
} }
fun setPrivacy(privacy: RoomPrivacy) { fun setRoomVisibility(visibility: RoomVisibilityItem) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy)) createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (visibility) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private
RoomVisibilityItem.Public -> RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(config.roomName.orEmpty()),
roomAddressErrorState = RoomAddressErrorState.None,
roomAccess = RoomAccess.Anyone,
)
}
)
}
}
fun setRoomAddress(address: String) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> config.roomVisibility.copy(roomAddress = RoomAddress.Edited(address))
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() { fun clearCachedData() {

View file

@ -1,101 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPrivacyOption(
roomPrivacyItem: RoomPrivacyItem,
onOptionClick: (RoomPrivacyItem) -> Unit,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
) {
Row(
modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onOptionClick(roomPrivacyItem) },
role = Role.RadioButton,
)
.padding(8.dp),
) {
Icon(
modifier = Modifier.padding(horizontal = 8.dp),
resourceId = roomPrivacyItem.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
)
Column(
Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) {
Text(
text = roomPrivacyItem.title,
style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.size(3.dp))
Text(
text = roomPrivacyItem.description,
style = ElementTheme.typography.fontBodySmRegular,
color = MaterialTheme.colorScheme.tertiary,
)
}
RadioButton(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(48.dp),
selected = isSelected,
// null recommended for accessibility with screenreaders
onClick = null
)
}
}
@PreviewsDayNight
@Composable
internal fun RoomPrivacyOptionPreview() = ElementPreview {
val aRoomPrivacyItem = roomPrivacyItems().first()
Column {
RoomPrivacyOption(
roomPrivacyItem = aRoomPrivacyItem,
onOptionClick = {},
isSelected = true,
)
RoomPrivacyOption(
roomPrivacyItem = aRoomPrivacyItem,
onOptionClick = {},
isSelected = false,
)
}
}

View file

@ -7,16 +7,17 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents { sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class RoomPrivacyChanged(val privacy: RoomPrivacy) : ConfigureRoomEvents data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
data class CreateRoom(val config: CreateRoomConfig) : 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 class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents data object CancelCreateRoom : ConfigureRoomEvents
} }

View file

@ -24,6 +24,8 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.mimetype.MimeTypes 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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId 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.CreateRoomParameters
@ -38,6 +40,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class ConfigureRoomPresenter @Inject constructor( class ConfigureRoomPresenter @Inject constructor(
@ -47,6 +50,7 @@ class ConfigureRoomPresenter @Inject constructor(
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
private val featureFlagService: FeatureFlagService,
) : Presenter<ConfigureRoomState> { ) : Presenter<ConfigureRoomState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false private var pendingPermissionRequest = false
@ -54,7 +58,9 @@ class ConfigureRoomPresenter @Inject constructor(
@Composable @Composable
override fun present(): ConfigureRoomState { override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig()) val createRoomConfig = dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) }, onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
@ -92,9 +98,11 @@ class ConfigureRoomPresenter @Inject constructor(
when (event) { when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy) is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem)
is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser) is ConfigureRoomEvents.RemoveUserFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
is ConfigureRoomEvents.CreateRoom -> createRoom(event.config) is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig.value)
is ConfigureRoomEvents.HandleAvatarAction -> { is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
@ -113,10 +121,12 @@ class ConfigureRoomPresenter @Inject constructor(
} }
return ConfigureRoomState( return ConfigureRoomState(
isKnockFeatureEnabled = isKnockFeatureEnabled,
config = createRoomConfig.value, config = createRoomConfig.value,
avatarActions = avatarActions, avatarActions = avatarActions,
createRoomAction = createRoomAction.value, createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -127,21 +137,40 @@ class ConfigureRoomPresenter @Inject constructor(
) = launch { ) = launch {
suspend { suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) } val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
val params = CreateRoomParameters( val params = if (config.roomVisibility is RoomVisibilityState.Public) {
name = config.roomName, CreateRoomParameters(
topic = config.topic, name = config.roomName,
isEncrypted = config.privacy == RoomPrivacy.Private, topic = config.topic,
isDirect = false, isEncrypted = false,
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE, isDirect = false,
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT, visibility = RoomVisibility.PUBLIC,
invite = config.invites.map { it.userId }, joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(),
avatar = avatarUrl, preset = RoomPreset.PUBLIC_CHAT,
) invite = config.invites.map { it.userId },
matrixClient.createRoom(params).getOrThrow() avatar = avatarUrl,
.also { canonicalAlias = config.roomVisibility.roomAddress()
)
} else {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
isDirect = false,
visibility = RoomVisibility.PRIVATE,
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() dataStore.clearCachedData()
analyticsService.capture(CreatedRoom(isDM = false)) analyticsService.capture(CreatedRoom(isDM = false))
} }
.getOrThrow()
}.runCatchingUpdatingState(createRoomAction) }.runCatchingUpdatingState(createRoomAction)
} }

View file

@ -15,11 +15,11 @@ import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState( data class ConfigureRoomState(
val isKnockFeatureEnabled: Boolean,
val config: CreateRoomConfig, val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: AsyncAction<RoomId>, val createRoomAction: AsyncAction<RoomId>,
val cameraPermissionState: PermissionsState, val cameraPermissionState: PermissionsState,
val homeserverName: String,
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvents) -> Unit
) { )
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()
}

View file

@ -10,30 +10,59 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.AsyncAction 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.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> { open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
override val values: Sequence<ConfigureRoomState> override val values: Sequence<ConfigureRoomState>
get() = sequenceOf( get() = sequenceOf(
aConfigureRoomState(), aConfigureRoomState(),
aConfigureRoomState().copy( aConfigureRoomState(
isKnockFeatureEnabled = false,
config = CreateRoomConfig( config = CreateRoomConfig(
roomName = "Room 101", 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", 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(), invites = aMatrixUserList().toImmutableList(),
privacy = RoomPrivacy.Public, roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room 101"),
roomAccess = RoomAccess.Knocking,
roomAddressErrorState = RoomAddressErrorState.None,
),
),
),
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,
roomAddressErrorState = RoomAddressErrorState.None,
),
), ),
), ),
) )
} }
fun aConfigureRoomState() = ConfigureRoomState( fun aConfigureRoomState(
config = CreateRoomConfig(), config: CreateRoomConfig = CreateRoomConfig(),
avatarActions = persistentListOf(), isKnockFeatureEnabled: Boolean = true,
createRoomAction = AsyncAction.Uninitialized, avatarActions: List<AvatarAction> = emptyList(),
cameraPermissionState = aPermissionsState(showDialog = false), createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink = { }, cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
homeserverName: String = "matrix.org",
eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState(
config = config,
isKnockFeatureEnabled = isKnockFeatureEnabled,
avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
eventSink = eventSink,
) )

View file

@ -11,9 +11,11 @@ import android.net.Uri
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -21,6 +23,7 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -33,18 +36,22 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.RoomPrivacyOption 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.LabelledTextField import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.async.AsyncActionView 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.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton 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.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId 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.AvatarActionBottomSheet
@ -72,11 +79,11 @@ fun ConfigureRoomView(
modifier = modifier.clearFocusOnTap(focusManager), modifier = modifier.clearFocusOnTap(focusManager),
topBar = { topBar = {
ConfigureRoomToolbar( ConfigureRoomToolbar(
isNextActionEnabled = state.isCreateButtonEnabled, isNextActionEnabled = state.config.isValid,
onBackClick = onBackClick, onBackClick = onBackClick,
onNextClick = { onNextClick = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) state.eventSink(ConfigureRoomEvents.CreateRoom)
}, },
) )
} }
@ -103,23 +110,42 @@ fun ConfigureRoomView(
) )
if (state.config.invites.isNotEmpty()) { if (state.config.invites.isNotEmpty()) {
SelectedUsersRowList( SelectedUsersRowList(
modifier = Modifier.padding(bottom = 16.dp),
contentPadding = PaddingValues(horizontal = 24.dp), contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites, selectedUsers = state.config.invites,
onUserRemove = { onUserRemove = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it)) state.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(it))
}, },
) )
} }
RoomPrivacyOptions( RoomVisibilityOptions(
modifier = Modifier.padding(bottom = 40.dp), selected = when (state.config.roomVisibility) {
selected = state.config.privacy, is RoomVisibilityState.Private -> RoomVisibilityItem.Private
is RoomVisibilityState.Public -> RoomVisibilityItem.Public
},
onOptionClick = { onOptionClick = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy)) 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,
homeserverName = state.homeserverName,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
)
}
} }
} }
@ -139,7 +165,7 @@ fun ConfigureRoomView(
}, },
onSuccess = { onCreateRoomSuccess(it) }, onSuccess = { onCreateRoomSuccess(it) },
errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) }, errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) },
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) }, onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
) )
@ -221,23 +247,123 @@ private fun RoomTopic(
} }
@Composable @Composable
private fun RoomPrivacyOptions( private fun ConfigureRoomOptions(
selected: RoomPrivacy?, title: String,
onOptionClick: (RoomPrivacyItem) -> Unit, 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, modifier: Modifier = Modifier,
) { ) {
val items = roomPrivacyItems() ConfigureRoomOptions(
Column(modifier = modifier.selectableGroup()) { title = stringResource(R.string.screen_create_room_room_visibility_section_title),
items.forEach { item -> modifier = modifier,
RoomPrivacyOption( ) {
roomPrivacyItem = item, RoomVisibilityItem.entries.forEach { item ->
isSelected = selected == item.privacy, val isSelected = item == selected
onOptionClick = onOptionClick, 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) },
)
}
}
}
@Composable
private fun RoomAddressField(
address: RoomAddress,
homeserverName: String,
onAddressChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.primary,
text = stringResource(R.string.screen_create_room_room_address_section_title),
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = address.value,
leadingIcon = {
Text(
text = "#",
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
trailingIcon = {
Text(
text = homeserverName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(end = 16.dp)
)
},
supportingText = {
Text(
text = stringResource(R.string.screen_create_room_room_address_section_footer),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
},
onValueChange = onAddressChange,
singleLine = true,
)
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview { internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview {

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.createroom.JoinRuleOverride
enum class RoomAccess {
Anyone,
Knocking
}
fun RoomAccess.toJoinRule(): JoinRuleOverride {
return when (this) {
RoomAccess.Anyone -> JoinRuleOverride.None
RoomAccess.Knocking -> JoinRuleOverride.Knock
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.annotation.StringRes
import io.element.android.features.createroom.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

@ -0,0 +1,13 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.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

@ -0,0 +1,17 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
/**
* Represents the error state of a room address.
*/
sealed interface RoomAddressErrorState {
data object InvalidCharacters : RoomAddressErrorState
data object AlreadyExists : RoomAddressErrorState
data object None : RoomAddressErrorState
}

View file

@ -1,13 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
enum class RoomPrivacy {
Private,
Public,
}

View file

@ -1,45 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RoomPrivacyItem(
val privacy: RoomPrivacy,
@DrawableRes val icon: Int,
val title: String,
val description: String,
)
@Composable
fun roomPrivacyItems(): ImmutableList<RoomPrivacyItem> {
return RoomPrivacy.entries
.map {
when (it) {
RoomPrivacy.Private -> RoomPrivacyItem(
privacy = it,
icon = CompoundDrawables.ic_compound_lock_solid,
title = stringResource(R.string.screen_create_room_private_option_title),
description = stringResource(R.string.screen_create_room_private_option_description),
)
RoomPrivacy.Public -> RoomPrivacyItem(
privacy = it,
icon = CompoundDrawables.ic_compound_public,
title = stringResource(R.string.screen_create_room_public_option_title),
description = stringResource(R.string.screen_create_room_public_option_description),
)
}
}
.toImmutableList()
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import io.element.android.features.createroom.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

@ -0,0 +1,34 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import java.util.Optional
sealed interface RoomVisibilityState {
data object Private : RoomVisibilityState
data class Public(
val roomAddress: RoomAddress,
val roomAddressErrorState: RoomAddressErrorState,
val roomAccess: RoomAccess,
) : RoomVisibilityState
fun roomAddress(): Optional<String> {
return when (this) {
is Private -> Optional.empty()
is Public -> Optional.of(roomAddress.value)
}
}
fun isValid(): Boolean {
return when (this) {
is Private -> true
is Public -> roomAddressErrorState is RoomAddressErrorState.None && roomAddress.value.isNotEmpty()
}
}
}

View file

@ -7,6 +7,9 @@
<string name="screen_create_room_private_option_title">"Прыватны пакой (толькі па запрашэнні)"</string> <string name="screen_create_room_private_option_title">"Прыватны пакой (толькі па запрашэнні)"</string>
<string name="screen_create_room_public_option_description">"Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."</string> <string name="screen_create_room_public_option_description">"Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."</string>
<string name="screen_create_room_public_option_title">"Публічны пакой (для ўсіх)"</string> <string name="screen_create_room_public_option_title">"Публічны пакой (для ўсіх)"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Хто заўгодна"</string>
<string name="screen_create_room_room_access_section_header">"Доступ у пакой"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Папрасіце далучыцца"</string>
<string name="screen_create_room_room_name_label">"Назва пакоя"</string> <string name="screen_create_room_room_name_label">"Назва пакоя"</string>
<string name="screen_create_room_title">"Стварыце пакой"</string> <string name="screen_create_room_title">"Стварыце пакой"</string>
<string name="screen_create_room_topic_label">"Тэма (неабавязкова)"</string> <string name="screen_create_room_topic_label">"Тэма (неабавязкова)"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli. <string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."</string> To můžete kdykoli změnit v nastavení místnosti."</string>
<string name="screen_create_room_public_option_title">"Veřejná místnost"</string> <string name="screen_create_room_public_option_title">"Veřejná místnost"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Do této místnosti může vstoupit kdokoli"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Kdokoliv"</string>
<string name="screen_create_room_room_access_section_header">"Přístup do místnosti"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Požádat o připojení"</string>
<string name="screen_create_room_room_address_section_footer">"Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti."</string>
<string name="screen_create_room_room_address_section_title">"Adresa místnosti"</string>
<string name="screen_create_room_room_name_label">"Název místnosti"</string> <string name="screen_create_room_room_name_label">"Název místnosti"</string>
<string name="screen_create_room_room_visibility_section_title">"Viditelnost místnosti"</string>
<string name="screen_create_room_title">"Vytvořit místnost"</string> <string name="screen_create_room_title">"Vytvořit místnost"</string>
<string name="screen_create_room_topic_label">"Téma (nepovinné)"</string> <string name="screen_create_room_topic_label">"Téma (nepovinné)"</string>
<string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string> <string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string>

View file

@ -8,6 +8,11 @@
<string name="screen_create_room_public_option_description">"Ο καθένας μπορεί να βρει αυτό το δωμάτιο. <string name="screen_create_room_public_option_description">"Ο καθένας μπορεί να βρει αυτό το δωμάτιο.
Μπορείς να το αλλάξεις ανά πάσα στιγμή στις ρυθμίσεις δωματίου."</string> Μπορείς να το αλλάξεις ανά πάσα στιγμή στις ρυθμίσεις δωματίου."</string>
<string name="screen_create_room_public_option_title">"Δημόσιο δωμάτιο"</string> <string name="screen_create_room_public_option_title">"Δημόσιο δωμάτιο"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτό το δωμάτιο"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Οποιοσδήποτε"</string>
<string name="screen_create_room_room_access_section_header">"Πρόσβαση Δωματίου"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στο δωμάτιο, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχθεί το αίτημα"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Αίτημα συμμετοχής"</string>
<string name="screen_create_room_room_name_label">"Όνομα δωματίου"</string> <string name="screen_create_room_room_name_label">"Όνομα δωματίου"</string>
<string name="screen_create_room_title">"Δημιούργησε ένα δωμάτιο"</string> <string name="screen_create_room_title">"Δημιούργησε ένα δωμάτιο"</string>
<string name="screen_create_room_topic_label">"Θέμα (προαιρετικό)"</string> <string name="screen_create_room_topic_label">"Θέμα (προαιρετικό)"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Kõik saavad seda jututuba leida. <string name="screen_create_room_public_option_description">"Kõik saavad seda jututuba leida.
Sa võid seda jututoa seadistustest alati muuta."</string> Sa võid seda jututoa seadistustest alati muuta."</string>
<string name="screen_create_room_public_option_title">"Avalik jututuba"</string> <string name="screen_create_room_public_option_title">"Avalik jututuba"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Kõik võivad selle jututoaga liituda"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Kõik"</string>
<string name="screen_create_room_room_access_section_header">"Ligipääs jututoale"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Küsi võimalust liitumiseks"</string>
<string name="screen_create_room_room_address_section_footer">"Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."</string>
<string name="screen_create_room_room_address_section_title">"Jututoa aadress"</string>
<string name="screen_create_room_room_name_label">"Jututoa nimi"</string> <string name="screen_create_room_room_name_label">"Jututoa nimi"</string>
<string name="screen_create_room_room_visibility_section_title">"Jututoa nähtavus"</string>
<string name="screen_create_room_title">"Loo jututuba"</string> <string name="screen_create_room_title">"Loo jututuba"</string>
<string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string> <string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string>
<string name="screen_start_chat_error_starting_chat">"Vestluse alustamisel tekkis viga"</string> <string name="screen_start_chat_error_starting_chat">"Vestluse alustamisel tekkis viga"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Nimporte qui peut trouver ce salon. <string name="screen_create_room_public_option_description">"Nimporte qui peut trouver ce salon.
Vous pouvez modifier cela à tout moment dans les paramètres du salon."</string> Vous pouvez modifier cela à tout moment dans les paramètres du salon."</string>
<string name="screen_create_room_public_option_title">"Salon public"</string> <string name="screen_create_room_public_option_title">"Salon public"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Tout le monde peut rejoindre ce salon"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Tout le monde"</string>
<string name="screen_create_room_room_access_section_header">"Accès au salon"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Tout le monde peut demander à rejoindre le salon, mais un administrateur ou un modérateur devra accepter la demande"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Demander à rejoindre"</string>
<string name="screen_create_room_room_address_section_footer">"Pour que ce salon soit visible dans le répertoire des salons publics, vous aurez besoin dune adresse de salon."</string>
<string name="screen_create_room_room_address_section_title">"Adresse du salon"</string>
<string name="screen_create_room_room_name_label">"Nom du salon"</string> <string name="screen_create_room_room_name_label">"Nom du salon"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilité du salon"</string>
<string name="screen_create_room_title">"Créer un salon"</string> <string name="screen_create_room_title">"Créer un salon"</string>
<string name="screen_create_room_topic_label">"Sujet (facultatif)"</string> <string name="screen_create_room_topic_label">"Sujet (facultatif)"</string>
<string name="screen_start_chat_error_starting_chat">"Une erreur sest produite lors de la tentative de création de la discussion"</string> <string name="screen_start_chat_error_starting_chat">"Une erreur sest produite lors de la tentative de création de la discussion"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Bárki megtalálhatja ezt a szobát. <string name="screen_create_room_public_option_description">"Bárki megtalálhatja ezt a szobát.
Ezt bármikor módosíthatja a szobabeállításokban."</string> Ezt bármikor módosíthatja a szobabeállításokban."</string>
<string name="screen_create_room_public_option_title">"Nyilvános szoba"</string> <string name="screen_create_room_public_option_title">"Nyilvános szoba"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Bárki csatlakozhat ehhez a szobához"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Bárki"</string>
<string name="screen_create_room_room_access_section_header">"Szobahozzáférés"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Csatlakozás kérése"</string>
<string name="screen_create_room_room_address_section_footer">"Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét."</string>
<string name="screen_create_room_room_address_section_title">"Szoba címe"</string>
<string name="screen_create_room_room_name_label">"Szoba neve"</string> <string name="screen_create_room_room_name_label">"Szoba neve"</string>
<string name="screen_create_room_room_visibility_section_title">"Szoba láthatósága"</string>
<string name="screen_create_room_title">"Szoba létrehozása"</string> <string name="screen_create_room_title">"Szoba létrehozása"</string>
<string name="screen_create_room_topic_label">"Téma (nem kötelező)"</string> <string name="screen_create_room_topic_label">"Téma (nem kötelező)"</string>
<string name="screen_start_chat_error_starting_chat">"Hiba történt a csevegés indításakor"</string> <string name="screen_start_chat_error_starting_chat">"Hiba történt a csevegés indításakor"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Siapa pun dapat mencari ruangan ini. <string name="screen_create_room_public_option_description">"Siapa pun dapat mencari ruangan ini.
Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."</string> Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."</string>
<string name="screen_create_room_public_option_title">"Ruangan publik"</string> <string name="screen_create_room_public_option_title">"Ruangan publik"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Siapa pun dapat bergabung dengan ruangan ini"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Siapa pun"</string>
<string name="screen_create_room_room_access_section_header">"Akses Ruangan"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Minta untuk bergabung"</string>
<string name="screen_create_room_room_address_section_footer">"Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."</string>
<string name="screen_create_room_room_address_section_title">"Alamat ruangan"</string>
<string name="screen_create_room_room_name_label">"Nama ruangan"</string> <string name="screen_create_room_room_name_label">"Nama ruangan"</string>
<string name="screen_create_room_room_visibility_section_title">"Keterlihatan ruangan"</string>
<string name="screen_create_room_title">"Buat ruangan"</string> <string name="screen_create_room_title">"Buat ruangan"</string>
<string name="screen_create_room_topic_label">"Topik (opsional)"</string> <string name="screen_create_room_topic_label">"Topik (opsional)"</string>
<string name="screen_start_chat_error_starting_chat">"Terjadi kesalahan saat mencoba memulai obrolan"</string> <string name="screen_start_chat_error_starting_chat">"Terjadi kesalahan saat mencoba memulai obrolan"</string>

View file

@ -8,6 +8,11 @@
<string name="screen_create_room_public_option_description">"Iedereen kan deze kamer vinden. <string name="screen_create_room_public_option_description">"Iedereen kan deze kamer vinden.
Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen."</string> Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen."</string>
<string name="screen_create_room_public_option_title">"Openbare kamer"</string> <string name="screen_create_room_public_option_title">"Openbare kamer"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Iedereen kan toetreden tot deze kamer"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Iedereen"</string>
<string name="screen_create_room_room_access_section_header">"Toegang tot de kamer"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Iedereen kan vragen om toe te treden tot de kamer, maar een beheerder of moderator moet het verzoek accepteren"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Vraag om toe te treden"</string>
<string name="screen_create_room_room_name_label">"Naam van de kamer"</string> <string name="screen_create_room_room_name_label">"Naam van de kamer"</string>
<string name="screen_create_room_title">"Creëer een kamer"</string> <string name="screen_create_room_title">"Creëer een kamer"</string>
<string name="screen_create_room_topic_label">"Onderwerp (optioneel)"</string> <string name="screen_create_room_topic_label">"Onderwerp (optioneel)"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Każdy może znaleźć ten pokój. <string name="screen_create_room_public_option_description">"Każdy może znaleźć ten pokój.
Możesz to zmienić w ustawieniach pokoju."</string> Możesz to zmienić w ustawieniach pokoju."</string>
<string name="screen_create_room_public_option_title">"Pokój publiczny"</string> <string name="screen_create_room_public_option_title">"Pokój publiczny"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Każdy może dołączyć do tego pokoju"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Wszyscy"</string>
<string name="screen_create_room_room_access_section_header">"Dostęp do pokoju"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić prośbę"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Poproś o dołączenie"</string>
<string name="screen_create_room_room_address_section_footer">"Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju."</string>
<string name="screen_create_room_room_address_section_title">"Adres pokoju"</string>
<string name="screen_create_room_room_name_label">"Nazwa pokoju"</string> <string name="screen_create_room_room_name_label">"Nazwa pokoju"</string>
<string name="screen_create_room_room_visibility_section_title">"Widoczność pomieszczenia"</string>
<string name="screen_create_room_title">"Utwórz pokój"</string> <string name="screen_create_room_title">"Utwórz pokój"</string>
<string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string> <string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string>
<string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string> <string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala. <string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala.
Pode alterar esta opção nas definições da sala."</string> Pode alterar esta opção nas definições da sala."</string>
<string name="screen_create_room_public_option_title">"Sala pública"</string> <string name="screen_create_room_public_option_title">"Sala pública"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Qualquer pessoa pode entrar nesta sala"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Qualquer pessoa"</string>
<string name="screen_create_room_room_access_section_header">"Acesso à sala"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou um moderador terá de aceitar o pedido"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Pedir para participar"</string>
<string name="screen_create_room_room_address_section_footer">"Para que esta sala seja visível no diretório público de salas, precisas de um endereço de sala."</string>
<string name="screen_create_room_room_address_section_title">"Endereço da sala"</string>
<string name="screen_create_room_room_name_label">"Nome da sala"</string> <string name="screen_create_room_room_name_label">"Nome da sala"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilidade da sala"</string>
<string name="screen_create_room_title">"Criar uma sala"</string> <string name="screen_create_room_title">"Criar uma sala"</string>
<string name="screen_create_room_topic_label">"Descrição (opcional)"</string> <string name="screen_create_room_topic_label">"Descrição (opcional)"</string>
<string name="screen_start_chat_error_starting_chat">"Ocorreu um erro ao tentar iniciar uma conversa"</string> <string name="screen_start_chat_error_starting_chat">"Ocorreu um erro ao tentar iniciar uma conversa"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Любой желающий может найти эту комнату. <string name="screen_create_room_public_option_description">"Любой желающий может найти эту комнату.
Вы можете изменить это в любое время в настройках комнаты."</string> Вы можете изменить это в любое время в настройках комнаты."</string>
<string name="screen_create_room_public_option_title">"Общедоступная комната"</string> <string name="screen_create_room_public_option_title">"Общедоступная комната"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Любой желающий может присоединиться к этой комнате"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Любой"</string>
<string name="screen_create_room_room_access_section_header">"Доступ в комнату"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Любой желающий может подать заявку на присоединение к комнате, но администратор или модератор должен будет принять запрос."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Попросить присоединиться"</string>
<string name="screen_create_room_room_address_section_footer">"Чтобы эта комната была видна в каталоге общедоступных, вам необходим ее адрес"</string>
<string name="screen_create_room_room_address_section_title">"Адрес комнаты"</string>
<string name="screen_create_room_room_name_label">"Название комнаты"</string> <string name="screen_create_room_room_name_label">"Название комнаты"</string>
<string name="screen_create_room_room_visibility_section_title">"Видимость комнаты"</string>
<string name="screen_create_room_title">"Создать комнату"</string> <string name="screen_create_room_title">"Создать комнату"</string>
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string> <string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
<string name="screen_start_chat_error_starting_chat">"Произошла ошибка при запуске чата"</string> <string name="screen_start_chat_error_starting_chat">"Произошла ошибка при запуске чата"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Túto miestnosť môže nájsť ktokoľvek. <string name="screen_create_room_public_option_description">"Túto miestnosť môže nájsť ktokoľvek.
Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."</string> Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."</string>
<string name="screen_create_room_public_option_title">"Verejná miestnosť"</string> <string name="screen_create_room_public_option_title">"Verejná miestnosť"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Do tejto miestnosti sa môže pripojiť ktokoľvek"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Ktokoľvek"</string>
<string name="screen_create_room_room_access_section_header">"Prístup do miestnosti"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Ktokoľvek môže požiadať o pripojenie sa k miestnosti, ale administrátor alebo moderátor bude musieť žiadosť schváliť"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Požiadať o pripojenie"</string>
<string name="screen_create_room_room_address_section_footer">"Aby bola táto miestnosť viditeľná v adresári verejných miestností, budete potrebovať adresu miestnosti."</string>
<string name="screen_create_room_room_address_section_title">"Adresa miestnosti"</string>
<string name="screen_create_room_room_name_label">"Názov miestnosti"</string> <string name="screen_create_room_room_name_label">"Názov miestnosti"</string>
<string name="screen_create_room_room_visibility_section_title">"Viditeľnosť miestnosti"</string>
<string name="screen_create_room_title">"Vytvoriť miestnosť"</string> <string name="screen_create_room_title">"Vytvoriť miestnosť"</string>
<string name="screen_create_room_topic_label">"Téma (voliteľné)"</string> <string name="screen_create_room_topic_label">"Téma (voliteľné)"</string>
<string name="screen_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string> <string name="screen_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Anyone can find this room. <string name="screen_create_room_public_option_description">"Anyone can find this room.
You can change this anytime in room settings."</string> You can change this anytime in room settings."</string>
<string name="screen_create_room_public_option_title">"Public room"</string> <string name="screen_create_room_public_option_title">"Public room"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Anyone can join this room"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Anyone"</string>
<string name="screen_create_room_room_access_section_header">"Room Access"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Anyone can ask to join the room but an administrator or a moderator will have to accept the request"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Ask to join"</string>
<string name="screen_create_room_room_address_section_footer">"In order for this room to be visible in the public room directory, you will need a room address."</string>
<string name="screen_create_room_room_address_section_title">"Room address"</string>
<string name="screen_create_room_room_name_label">"Room name"</string> <string name="screen_create_room_room_name_label">"Room name"</string>
<string name="screen_create_room_room_visibility_section_title">"Room visibility"</string>
<string name="screen_create_room_title">"Create a room"</string> <string name="screen_create_room_title">"Create a room"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</string> <string name="screen_create_room_topic_label">"Topic (optional)"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string> <string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>

View file

@ -8,15 +8,16 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionMode import app.cash.turbine.TurbineTestContext
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
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 io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
@ -25,13 +26,18 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser 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.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
@ -56,33 +62,8 @@ class ConfigureRoomPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private lateinit var presenter: ConfigureRoomPresenter
private lateinit var userListDataStore: UserListDataStore
private lateinit var createRoomDataStore: CreateRoomDataStore
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
private lateinit var fakeAnalyticsService: FakeAnalyticsService
private lateinit var fakePermissionsPresenter: FakePermissionsPresenter
@Before @Before
fun setup() { fun setup() {
fakeMatrixClient = FakeMatrixClient()
userListDataStore = UserListDataStore()
createRoomDataStore = CreateRoomDataStore(userListDataStore)
fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor()
fakeAnalyticsService = FakeAnalyticsService()
fakePermissionsPresenter = FakePermissionsPresenter()
presenter = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
matrixClient = fakeMatrixClient,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
analyticsService = fakeAnalyticsService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(fakePermissionsPresenter),
)
mockkStatic(File::readBytes) mockkStatic(File::readBytes)
every { any<File>().readBytes() } returns byteArrayOf() every { any<File>().readBytes() } returns byteArrayOf()
} }
@ -94,50 +75,56 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val presenter = createConfigureRoomPresenter()
presenter.present() presenter.test {
}.test { val initialState = initialState()
val initialState = awaitItem()
assertThat(initialState.config).isEqualTo(CreateRoomConfig()) assertThat(initialState.config).isEqualTo(CreateRoomConfig())
assertThat(initialState.config.roomName).isNull() assertThat(initialState.config.roomName).isNull()
assertThat(initialState.config.topic).isNull() assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty() assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull() assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.privacy).isEqualTo(RoomPrivacy.Private) assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private)
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org")
} }
} }
@Test @Test
fun `present - create room button is enabled only if the required fields are completed`() = runTest { fun `present - create room button is enabled only if the required fields are completed`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val presenter = createConfigureRoomPresenter()
presenter.present() presenter.test {
}.test { val initialState = initialState()
val initialState = awaitItem()
var config = initialState.config var config = initialState.config
assertThat(initialState.isCreateButtonEnabled).isFalse() assertThat(initialState.config.isValid).isFalse()
// Room name not empty // Room name not empty
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
var newState: ConfigureRoomState = awaitItem() var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME) config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config) assertThat(newState.config).isEqualTo(config)
assertThat(newState.isCreateButtonEnabled).isTrue() assertThat(newState.config.isValid).isTrue()
// Clear room name // Clear room name
newState.eventSink(ConfigureRoomEvents.RoomNameChanged("")) newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
newState = awaitItem() newState = awaitItem()
config = config.copy(roomName = null) config = config.copy(roomName = null)
assertThat(newState.config).isEqualTo(config) assertThat(newState.config).isEqualTo(config)
assertThat(newState.isCreateButtonEnabled).isFalse() assertThat(newState.config.isValid).isFalse()
} }
} }
@Test @Test
fun `present - state is updated when fields are changed`() = runTest { fun `present - state is updated when fields are changed`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val userListDataStore = UserListDataStore()
presenter.present() val pickerProvider = FakePickerProvider()
}.test { val permissionsPresenter = FakePermissionsPresenter()
val initialState = awaitItem() val presenter = createConfigureRoomPresenter(
createRoomDataStore = CreateRoomDataStore(userListDataStore),
pickerProvider = pickerProvider,
permissionsPresenter = permissionsPresenter,
)
presenter.test {
val initialState = initialState()
var expectedConfig = CreateRoomConfig() var expectedConfig = CreateRoomConfig()
assertThat(initialState.config).isEqualTo(expectedConfig) assertThat(initialState.config).isEqualTo(expectedConfig)
@ -165,22 +152,22 @@ class ConfigureRoomPresenterTest {
// Room avatar // Room avatar
// Pick avatar // Pick avatar
fakePickerProvider.givenResult(null) pickerProvider.givenResult(null)
// From gallery // From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY) val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
fakePickerProvider.givenResult(uriFromGallery) pickerProvider.givenResult(uriFromGallery)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery) expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// From camera // From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA) val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
fakePickerProvider.givenResult(uriFromCamera) pickerProvider.givenResult(uriFromCamera)
assertThat(newState.cameraPermissionState.permissionGranted).isFalse() assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem() newState = awaitItem()
assertThat(newState.cameraPermissionState.showDialog).isTrue() assertThat(newState.cameraPermissionState.showDialog).isTrue()
fakePermissionsPresenter.setPermissionGranted() permissionsPresenter.setPermissionGranted()
newState = awaitItem() newState = awaitItem()
assertThat(newState.cameraPermissionState.permissionGranted).isTrue() assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
newState = awaitItem() newState = awaitItem()
@ -188,7 +175,7 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Do it again, no permission is requested // Do it again, no permission is requested
val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2) val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
fakePickerProvider.givenResult(uriFromCamera2) pickerProvider.givenResult(uriFromCamera2)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2) expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
@ -200,13 +187,19 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy // Room privacy
newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Public)) newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(privacy = RoomPrivacy.Public) expectedConfig = expectedConfig.copy(
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(expectedConfig.roomName ?: ""),
roomAddressErrorState = RoomAddressErrorState.None,
roomAccess = RoomAccess.Anyone,
)
)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Remove user // Remove user
newState.eventSink(ConfigureRoomEvents.RemoveFromSelection(selectedUser1)) newState.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(selectedUser1))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList()) expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
@ -215,15 +208,17 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - trigger create room action`() = runTest { fun `present - trigger create room action`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val matrixClient = createMatrixClient()
presenter.present() val presenter = createConfigureRoomPresenter(
}.test { matrixClient = matrixClient
val initialState = awaitItem() )
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
fakeMatrixClient.givenCreateRoomResult(createRoomResult) matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@ -233,18 +228,22 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - record analytics when creating room`() = runTest { fun `present - record analytics when creating room`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val matrixClient = createMatrixClient()
presenter.present() val analyticsService = FakeAnalyticsService()
}.test { val presenter = createConfigureRoomPresenter(
val initialState = awaitItem() matrixClient = matrixClient,
analyticsService = analyticsService
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
fakeMatrixClient.givenCreateRoomResult(createRoomResult) matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) initialState.eventSink(ConfigureRoomEvents.CreateRoom)
skipItems(2) skipItems(2)
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull() val analyticsEvent = analyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
assertThat(analyticsEvent).isNotNull() assertThat(analyticsEvent).isNotNull()
assertThat(analyticsEvent?.isDM).isFalse() assertThat(analyticsEvent?.isDM).isFalse()
} }
@ -252,23 +251,31 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - trigger create room with upload error and retry`() = runTest { fun `present - trigger create room with upload error and retry`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val matrixClient = createMatrixClient()
presenter.present() val analyticsService = FakeAnalyticsService()
}.test { val mediaPreProcessor = FakeMediaPreProcessor()
skipItems(1) val createRoomDataStore = CreateRoomDataStore(UserListDataStore())
val presenter = createConfigureRoomPresenter(
createRoomDataStore = createRoomDataStore,
mediaPreProcessor = mediaPreProcessor,
matrixClient = matrixClient,
analyticsService = analyticsService
)
presenter.test {
val initialState = initialState()
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY)) createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk()))) skipItems(1)
fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE)) mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
matrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
val initialState = awaitItem() initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() assertThat(analyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@ -277,23 +284,25 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - trigger retry and cancel actions`() = runTest { fun `present - trigger retry and cancel actions`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val fakeMatrixClient = createMatrixClient()
presenter.present() val presenter = createConfigureRoomPresenter(
}.test { matrixClient = fakeMatrixClient
val initialState = awaitItem() )
presenter.test {
val initialState = initialState()
val createRoomResult = Result.failure<RoomId>(A_THROWABLE) val createRoomResult = Result.failure<RoomId>(A_THROWABLE)
fakeMatrixClient.givenCreateRoomResult(createRoomResult) fakeMatrixClient.givenCreateRoomResult(createRoomResult)
// Create // Create
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull()) assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Retry // Retry
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterRetry = awaitItem() val stateAfterRetry = awaitItem()
@ -305,4 +314,33 @@ class ConfigureRoomPresenterTest {
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
} }
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
}
private fun createMatrixClient() = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
)
private fun createConfigureRoomPresenter(
createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore()),
matrixClient: MatrixClient = createMatrixClient(),
pickerProvider: PickerProvider = FakePickerProvider(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
) = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
matrixClient = matrixClient,
mediaPickerProvider = pickerProvider,
mediaPreProcessor = mediaPreProcessor,
analyticsService = analyticsService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
)
)
} }

View file

@ -29,6 +29,7 @@ dependencies {
implementation(projects.features.call.api) implementation(projects.features.call.api)
implementation(projects.features.location.api) implementation(projects.features.location.api)
implementation(projects.features.poll.api) implementation(projects.features.poll.api)
implementation(projects.features.roomcall.api)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)

View file

@ -50,6 +50,7 @@ import io.element.android.features.messages.impl.timeline.protection.TimelinePro
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -75,7 +76,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -98,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>, private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>, private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>, private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
@ -133,6 +134,7 @@ class MessagesPresenter @AssistedInject constructor(
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
@ -152,8 +154,6 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false) mutableStateOf(false)
} }
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts // Remove the unread flag on entering but don't send read receipts
// as those will be handled by the timeline. // as those will be handled by the timeline.
@ -204,12 +204,6 @@ class MessagesPresenter @AssistedInject constructor(
} }
} }
val callState = when {
!canJoinCall -> RoomCallState.DISABLED
roomInfo?.hasRoomCall == true -> RoomCallState.ONGOING
else -> RoomCallState.ENABLED
}
return MessagesState( return MessagesState(
roomId = room.roomId, roomId = room.roomId,
roomName = roomName, roomName = roomName,
@ -232,7 +226,7 @@ class MessagesPresenter @AssistedInject constructor(
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING, enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
callState = callState, roomCallState = roomCallState,
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )

View file

@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -46,14 +47,8 @@ data class MessagesState(
val showReinvitePrompt: Boolean, val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean, val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean, val enableVoiceMessages: Boolean,
val callState: RoomCallState, val roomCallState: RoomCallState,
val appName: String, val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState, val pinnedMessagesBannerState: PinnedMessagesBannerState,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) )
enum class RoomCallState {
ENABLED,
ONGOING,
DISABLED
}

View file

@ -33,6 +33,9 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomcall.api.anOngoingCallState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -70,7 +73,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
), ),
), ),
aMessagesState( aMessagesState(
callState = RoomCallState.ONGOING, roomCallState = anOngoingCallState(),
), ),
aMessagesState( aMessagesState(
enableVoiceMessages = true, enableVoiceMessages = true,
@ -80,7 +83,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
), ),
), ),
aMessagesState( aMessagesState(
callState = RoomCallState.DISABLED, roomCallState = aStandByCallState(canStartCall = false),
), ),
aMessagesState( aMessagesState(
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState( pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
@ -115,7 +118,7 @@ fun aMessagesState(
hasNetworkConnection: Boolean = true, hasNetworkConnection: Boolean = true,
showReinvitePrompt: Boolean = false, showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true, enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED, roomCallState: RoomCallState = aStandByCallState(),
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(), pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {}, eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState( ) = MessagesState(
@ -139,7 +142,7 @@ fun aMessagesState(
showReinvitePrompt = showReinvitePrompt, showReinvitePrompt = showReinvitePrompt,
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
callState = callState, roomCallState = roomCallState,
appName = "Element", appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = eventSink, eventSink = eventSink,

View file

@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -69,7 +68,7 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
@ -81,6 +80,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
@ -93,8 +93,6 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -190,7 +188,7 @@ fun MessagesView(
roomName = state.roomName.dataOrNull(), roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(), roomAvatar = state.roomAvatar.dataOrNull(),
heroes = state.heroes, heroes = state.heroes,
callState = state.callState, roomCallState = state.roomCallState,
onBackClick = { onBackClick = {
// Since the textfield is now based on an Android view, this is no longer done automatically. // Since the textfield is now based on an Android view, this is no longer done automatically.
// We need to hide the keyboard when navigating out of this screen. // We need to hide the keyboard when navigating out of this screen.
@ -479,7 +477,7 @@ private fun MessagesViewTopBar(
roomName: String?, roomName: String?,
roomAvatar: AvatarData?, roomAvatar: AvatarData?,
heroes: ImmutableList<AvatarData>, heroes: ImmutableList<AvatarData>,
callState: RoomCallState, roomCallState: RoomCallState,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
@ -509,9 +507,8 @@ private fun MessagesViewTopBar(
}, },
actions = { actions = {
CallMenuItem( CallMenuItem(
isCallOngoing = callState == RoomCallState.ONGOING, roomCallState = roomCallState,
onClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
enabled = callState != RoomCallState.DISABLED
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
}, },
@ -519,24 +516,6 @@ private fun MessagesViewTopBar(
) )
} }
@Composable
private fun CallMenuItem(
isCallOngoing: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
if (isCallOngoing) {
JoinCallMenuItem(onJoinCallClick = onClick)
} else {
IconButton(onClick = onClick, enabled = enabled) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
}
}
@Composable @Composable
private fun RoomAvatarAndNameRow( private fun RoomAvatarAndNameRow(
roomName: String, roomName: String,

View file

@ -9,16 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -30,6 +35,7 @@ import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor( class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment, @Assisted private val attachment: Attachment,
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val permalinkBuilder: PermalinkBuilder,
) : Presenter<AttachmentsPreviewState> { ) : Presenter<AttachmentsPreviewState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -44,11 +50,24 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mutableStateOf<SendActionState>(SendActionState.Idle) mutableStateOf<SendActionState>(SendActionState.Idle)
} }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
TextEditorState.Markdown(markdownTextEditorState)
)
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) { when (attachmentsPreviewEvents) {
AttachmentsPreviewEvents.SendAttachment -> ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(attachment, sendActionState) is AttachmentsPreviewEvents.SendAttachment -> {
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(
attachment = attachment,
caption = caption,
sendActionState = sendActionState,
)
}
AttachmentsPreviewEvents.ClearSendState -> { AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let { ongoingSendAttachmentJob.value?.let {
it.cancel() it.cancel()
@ -62,18 +81,21 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
return AttachmentsPreviewState( return AttachmentsPreviewState(
attachment = attachment, attachment = attachment,
sendActionState = sendActionState.value, sendActionState = sendActionState.value,
textEditorState = textEditorState,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.sendAttachment( private fun CoroutineScope.sendAttachment(
attachment: Attachment, attachment: Attachment,
caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) = launch { ) = launch {
when (attachment) { when (attachment) {
is Attachment.Media -> { is Attachment.Media -> {
sendMedia( sendMedia(
mediaAttachment = attachment, mediaAttachment = attachment,
caption = caption,
sendActionState = sendActionState, sendActionState = sendActionState,
) )
} }
@ -82,6 +104,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private suspend fun sendMedia( private suspend fun sendMedia(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) = runCatching { ) = runCatching {
val context = coroutineContext val context = coroutineContext
@ -96,6 +119,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaSender.sendMedia( mediaSender.sendMedia(
uri = mediaAttachment.localMedia.uri, uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType, mimeType = mediaAttachment.localMedia.info.mimeType,
caption = caption,
progressCallback = progressCallback progressCallback = progressCallback
).getOrThrow() ).getOrThrow()
}.fold( }.fold(

View file

@ -9,12 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.textcomposer.model.TextEditorState
data class AttachmentsPreviewState( data class AttachmentsPreviewState(
val attachment: Attachment, val attachment: Attachment,
val sendActionState: SendActionState, val sendActionState: SendActionState,
val textEditorState: TextEditorState,
val eventSink: (AttachmentsPreviewEvents) -> Unit val eventSink: (AttachmentsPreviewEvents) -> Unit
) ) {
val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
it.isMimeTypeImage() || it.isMimeTypeVideo()
}.orFalse()
}
@Immutable @Immutable
sealed interface SendActionState { sealed interface SendActionState {

View file

@ -12,13 +12,19 @@ import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> { open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState> override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf( get() = sequenceOf(
anAttachmentsPreviewState(), anAttachmentsPreviewState(),
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()), anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
@ -27,11 +33,13 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
fun anAttachmentsPreviewState( fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(), mediaInfo: MediaInfo = anImageMediaInfo(),
sendActionState: SendActionState = SendActionState.Idle textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
) = AttachmentsPreviewState( ) = AttachmentsPreviewState(
attachment = Attachment.Media( attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo), localMedia = LocalMedia("file://path".toUri(), mediaInfo),
), ),
sendActionState = sendActionState, sendActionState = sendActionState,
textEditorState = textEditorState,
eventSink = {} eventSink = {}
) )

View file

@ -9,37 +9,44 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.display.TextDisplay
import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.rememberZoomableState
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AttachmentsPreviewView( fun AttachmentsPreviewView(
state: AttachmentsPreviewState, state: AttachmentsPreviewState,
@ -61,11 +68,23 @@ fun AttachmentsPreviewView(
} }
} }
Scaffold(modifier) { Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = onDismiss,
)
},
title = {},
)
}
) {
AttachmentPreviewContent( AttachmentPreviewContent(
attachment = state.attachment, state = state,
onSendClick = ::postSendAttachment, onSendClick = ::postSendAttachment,
onDismiss = onDismiss
) )
} }
AttachmentSendStateView( AttachmentSendStateView(
@ -106,21 +125,19 @@ private fun AttachmentSendStateView(
@Composable @Composable
private fun AttachmentPreviewContent( private fun AttachmentPreviewContent(
attachment: Attachment, state: AttachmentsPreviewState,
onSendClick: () -> Unit, onSendClick: () -> Unit,
onDismiss: () -> Unit,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding(), .navigationBarsPadding(),
contentAlignment = Alignment.BottomCenter
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when (attachment) { when (val attachment = state.attachment) {
is Attachment.Media -> { is Attachment.Media -> {
val localMediaViewState = rememberLocalMediaViewState( val localMediaViewState = rememberLocalMediaViewState(
zoomableState = rememberZoomableState( zoomableState = rememberZoomableState(
@ -137,27 +154,46 @@ private fun AttachmentPreviewContent(
} }
} }
AttachmentsPreviewBottomActions( AttachmentsPreviewBottomActions(
onCancelClick = onDismiss, state = state,
onSendClick = onSendClick, onSendClick = onSendClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = 0.7f)) .background(ElementTheme.colors.bgCanvasDefault)
.padding(horizontal = 24.dp) .height(IntrinsicSize.Min)
.defaultMinSize(minHeight = 80.dp) .align(Alignment.BottomCenter)
.imePadding(),
) )
} }
} }
@Composable @Composable
private fun AttachmentsPreviewBottomActions( private fun AttachmentsPreviewBottomActions(
onCancelClick: () -> Unit, state: AttachmentsPreviewState,
onSendClick: () -> Unit, onSendClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
ButtonRowMolecule(modifier = modifier) { TextComposer(
TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick) modifier = modifier,
TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick) state = state.textEditorState,
} voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Attachment(state.allowCaption),
onRequestFocus = {},
onSendMessage = onSendClick,
showTextFormatting = false,
onResetComposerMode = {},
onAddAttachment = {},
onDismissTextFormatting = {},
enableVoiceMessages = false,
onVoiceRecorderEvent = {},
onVoicePlayerEvent = {},
onSendVoiceMessage = {},
onDeleteVoiceMessage = {},
onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
onError = {},
onTyping = {},
onSelectRichContent = {},
)
} }
// Only preview in dark, dark theme is forced on the Node. // Only preview in dark, dark theme is forced on the Node.

View file

@ -436,6 +436,7 @@ class MessageComposerPresenter @Inject constructor(
// Reset composer right away // Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) { when (capturedMode) {
is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> room.sendMessage( is MessageComposerMode.Normal -> room.sendMessage(
body = message.markdown, body = message.markdown,
htmlBody = message.html, htmlBody = message.html,
@ -605,6 +606,7 @@ class MessageComposerPresenter @Inject constructor(
): ComposerDraft? { ): ComposerDraft? {
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false) val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false)
val draftType = when (val mode = messageComposerContext.composerMode) { val draftType = when (val mode = messageComposerContext.composerMode) {
is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }

View file

@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -89,7 +90,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
// We don't need to compute those values // We don't need to compute those values
userHasPermissionToSendMessage = false, userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false, userHasPermissionToSendReaction = false,
isCallOngoing = false, // We do not care about the call state here.
roomCallState = aStandByCallState(),
// don't compute this value or the pin icon will be shown // don't compute this value or the pin icon will be shown
pinnedEventIds = emptyList(), pinnedEventIds = emptyList(),
typingNotificationState = TypingNotificationState( typingNotificationState = TypingNotificationState(

View file

@ -32,8 +32,8 @@ import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
@ -73,6 +73,7 @@ class TimelinePresenter @AssistedInject constructor(
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>, private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>, private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -229,14 +230,15 @@ class TimelinePresenter @AssistedInject constructor(
} }
val typingNotificationState = typingNotificationPresenter.present() val typingNotificationState = typingNotificationPresenter.present()
val timelineRoomInfo by remember(typingNotificationState) { val roomCallState = roomCallStatePresenter.present()
val timelineRoomInfo by remember(typingNotificationState, roomCallState) {
derivedStateOf { derivedStateOf {
TimelineRoomInfo( TimelineRoomInfo(
name = room.displayName, name = room.displayName,
isDm = room.isDm, isDm = room.isDm,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction, userHasPermissionToSendReaction = userHasPermissionToSendReaction,
isCallOngoing = roomInfo?.hasRoomCall.orFalse(), roomCallState = roomCallState,
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(), pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
typingNotificationState = typingNotificationState, typingNotificationState = typingNotificationState,
) )

View file

@ -12,6 +12,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -73,7 +74,7 @@ data class TimelineRoomInfo(
val name: String?, val name: String?,
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean, val userHasPermissionToSendReaction: Boolean,
val isCallOngoing: Boolean, val roomCallState: RoomCallState,
val pinnedEventIds: List<EventId>, val pinnedEventIds: List<EventId>,
val typingNotificationState: TypingNotificationState, val typingNotificationState: TypingNotificationState,
) )

View file

@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -249,7 +250,7 @@ internal fun aTimelineRoomInfo(
name = name, name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true, userHasPermissionToSendReaction = true,
isCallOngoing = false, roomCallState = aStandByCallState(),
pinnedEventIds = pinnedEventIds, pinnedEventIds = pinnedEventIds,
typingNotificationState = typingNotificationState, typingNotificationState = typingNotificationState,
) )

View file

@ -0,0 +1,120 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.RoomCallStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun CallMenuItem(
roomCallState: RoomCallState,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (roomCallState) {
is RoomCallState.StandBy -> {
StandByCallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
modifier = modifier,
)
}
is RoomCallState.OnGoing -> {
OnGoingCallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
modifier = modifier,
)
}
}
}
@Composable
private fun StandByCallMenuItem(
roomCallState: RoomCallState.StandBy,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier,
onClick = onJoinCallClick,
enabled = roomCallState.canStartCall,
) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
}
@Composable
private fun OnGoingCallMenuItem(
roomCallState: RoomCallState.OnGoing,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!roomCallState.isUserLocallyInTheCall) {
Button(
onClick = onJoinCallClick,
colors = ButtonDefaults.buttonColors(
contentColor = ElementTheme.colors.bgCanvasDefault,
containerColor = ElementTheme.colors.iconAccentTertiary
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
modifier = modifier.heightIn(min = 36.dp),
enabled = roomCallState.canJoinCall,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(CommonStrings.action_join),
style = ElementTheme.typography.fontBodyMdMedium
)
Spacer(Modifier.width(8.dp))
}
} else {
// Else user is already in the call, hide the button.
Box(modifier)
}
}
@PreviewsDayNight
@Composable
internal fun CallMenuItemPreview(
@PreviewParameter(RoomCallStateProvider::class) roomCallState: RoomCallState
) = ElementPreview {
CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = {}
)
}

View file

@ -1,52 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun JoinCallMenuItem(
onJoinCallClick: () -> Unit,
) {
Button(
onClick = onJoinCallClick,
colors = ButtonDefaults.buttonColors(
contentColor = ElementTheme.colors.bgCanvasDefault,
containerColor = ElementTheme.colors.iconAccentTertiary
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
modifier = Modifier.heightIn(min = 36.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(CommonStrings.action_join),
style = ElementTheme.typography.fontBodyMdMedium
)
Spacer(Modifier.width(8.dp))
}
}

View file

@ -31,6 +31,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.RoomCallStateProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -41,7 +43,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun TimelineItemCallNotifyView( internal fun TimelineItemCallNotifyView(
event: TimelineItem.Event, event: TimelineItem.Event,
isCallOngoing: Boolean, roomCallState: RoomCallState,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -82,8 +84,11 @@ internal fun TimelineItemCallNotifyView(
) )
} }
} }
if (isCallOngoing) { if (roomCallState is RoomCallState.OnGoing) {
JoinCallMenuItem(onJoinCallClick) CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
)
} else { } else {
Text( Text(
text = event.sentTime, text = event.sentTime,
@ -101,18 +106,14 @@ internal fun TimelineItemCallNotifyView(
internal fun TimelineItemCallNotifyViewPreview() { internal fun TimelineItemCallNotifyViewPreview() {
ElementPreview { ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
TimelineItemCallNotifyView( RoomCallStateProvider().values.forEach { roomCallState ->
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()), TimelineItemCallNotifyView(
isCallOngoing = true, event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
onLongClick = {}, roomCallState = roomCallState,
onJoinCallClick = {}, onLongClick = {},
) onJoinCallClick = {},
TimelineItemCallNotifyView( )
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()), }
isCallOngoing = false,
onLongClick = {},
onJoinCallClick = {},
)
} }
} }
} }

View file

@ -105,7 +105,7 @@ internal fun TimelineItemRow(
TimelineItemCallNotifyView( TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
event = timelineItem, event = timelineItem,
isCallOngoing = timelineRoomInfo.isCallOngoing, roomCallState = timelineRoomInfo.roomCallState,
onLongClick = onLongClick, onLongClick = onLongClick,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
) )

View file

@ -70,14 +70,14 @@ fun TimelineItemVideoView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val description = stringResource(CommonStrings.common_image) val description = stringResource(CommonStrings.common_video)
Column( Column(
modifier = modifier.semantics { contentDescription = description } modifier = modifier.semantics { contentDescription = description }
) { ) {
val containerModifier = if (content.showCaption) { val containerModifier = if (content.showCaption) {
Modifier Modifier
.padding(top = 6.dp) .padding(top = 6.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
} else { } else {
Modifier Modifier
} }
@ -93,8 +93,8 @@ fun TimelineItemVideoView(
var isLoaded by remember { mutableStateOf(false) } var isLoaded by remember { mutableStateOf(false) }
AsyncImage( AsyncImage(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier), .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData( model = MediaRequestData(
source = content.thumbnailSource, source = content.thumbnailSource,
kind = MediaRequestData.Kind.File( kind = MediaRequestData.Kind.File(

View file

@ -37,6 +37,7 @@ import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvi
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -139,27 +140,6 @@ class MessagesPresenterTest {
} }
} }
@Test
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(false) },
canUserSendMessageResult = { _, _ -> Result.success(true) },
canRedactOwnResult = { Result.success(true) },
canRedactOtherResult = { Result.success(true) },
typingNoticeResult = { Result.success(Unit) },
canUserPinUnpinResult = { Result.success(true) },
).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
val presenter = createMessagesPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.callState).isEqualTo(RoomCallState.DISABLED)
}
}
@Test @Test
fun `present - handle toggling a reaction`() = runTest { fun `present - handle toggling a reaction`() = runTest {
val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
@ -1030,6 +1010,7 @@ class MessagesPresenterTest {
readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() }, readReceiptBottomSheetPresenter = { aReadReceiptBottomSheetState() },
identityChangeStatePresenter = { anIdentityChangeState() }, identityChangeStatePresenter = { anIdentityChangeState() },
pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() }, pinnedMessagesBannerPresenter = { aLoadedPinnedMessagesBannerState() },
roomCallStatePresenter = { aStandByCallState() },
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
navigator = navigator, navigator = navigator,

View file

@ -20,8 +20,13 @@ import io.element.android.features.messages.impl.attachments.preview.SendActionS
import io.element.android.features.messages.impl.fixtures.aMediaAttachment import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
@ -30,19 +35,23 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File import java.io.File
@RunWith(RobolectricTestRunner::class)
class AttachmentsPreviewPresenterTest { class AttachmentsPreviewPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private val mediaPreProcessor = FakeMediaPreProcessor()
private val mockMediaUrl: Uri = mockk("localMediaUri") private val mockMediaUrl: Uri = mockk("localMediaUri")
@Test @Test
@ -75,6 +84,80 @@ class AttachmentsPreviewPresenterTest {
} }
} }
@Test
fun `present - send image with caption success scenario`() = runTest {
val sendImageResult =
lambdaRecorder<File, File?, ImageInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
givenImageResult()
}
val room = FakeMatrixRoom(
sendImageResult = sendImageResult,
)
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = mediaPreProcessor,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val successState = awaitItem()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendImageResult.assertions().isCalledOnce().with(
any(),
any(),
any(),
value(A_CAPTION),
any(),
any(),
)
}
}
@Test
fun `present - send video with caption success scenario`() = runTest {
val sendVideoResult =
lambdaRecorder<File, File?, VideoInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
givenVideoResult()
}
val room = FakeMatrixRoom(
sendVideoResult = sendVideoResult,
)
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = mediaPreProcessor,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val successState = awaitItem()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendVideoResult.assertions().isCalledOnce().with(
any(),
any(),
any(),
value(A_CAPTION),
any(),
any(),
)
}
}
@Test @Test
fun `present - send media failure scenario`() = runTest { fun `present - send media failure scenario`() = runTest {
val failure = MediaPreProcessor.Failure(null) val failure = MediaPreProcessor.Failure(null)
@ -121,11 +204,14 @@ class AttachmentsPreviewPresenterTest {
localMedia: LocalMedia = aLocalMedia( localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl, uri = mockMediaUrl,
), ),
room: MatrixRoom = FakeMatrixRoom() room: MatrixRoom = FakeMatrixRoom(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()) mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
permalinkBuilder = permalinkBuilder,
) )
} }
} }

View file

@ -27,6 +27,7 @@ import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@ -685,5 +686,6 @@ internal fun TestScope.createTimelinePresenter(
timelineController = TimelineController(room), timelineController = TimelineController(room),
resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() }, resolveVerifiedUserSendFailurePresenter = { aResolveVerifiedUserSendFailureState() },
typingNotificationPresenter = { aTypingNotificationState() }, typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() },
) )
} }

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrElse
class RoomAliasResolverPresenter @AssistedInject constructor( class RoomAliasResolverPresenter @AssistedInject constructor(
@Assisted private val roomAlias: RoomAlias, @Assisted private val roomAlias: RoomAlias,
@ -57,7 +58,9 @@ class RoomAliasResolverPresenter @AssistedInject constructor(
private fun CoroutineScope.resolveAlias(resolveState: MutableState<AsyncData<ResolvedRoomAlias>>) = launch { private fun CoroutineScope.resolveAlias(resolveState: MutableState<AsyncData<ResolvedRoomAlias>>) = launch {
suspend { suspend {
matrixClient.resolveRoomAlias(roomAlias).getOrThrow() matrixClient.resolveRoomAlias(roomAlias)
.getOrThrow()
.getOrElse { error("Failed to resolve room alias $roomAlias") }
}.runCatchingUpdatingState(resolveState) }.runCatchingUpdatingState(resolveState)
} }
} }

View file

@ -24,6 +24,7 @@ import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.Optional
class RoomAliasResolverPresenterTest { class RoomAliasResolverPresenterTest {
@get:Rule @get:Rule
@ -42,7 +43,7 @@ class RoomAliasResolverPresenterTest {
@Test @Test
fun `present - resolve alias to roomId`() = runTest { fun `present - resolve alias to roomId`() = runTest {
val result = aResolvedRoomAlias() val result = Optional.of(aResolvedRoomAlias())
val client = FakeMatrixClient( val client = FakeMatrixClient(
resolveRoomAliasResult = { Result.success(result) } resolveRoomAliasResult = { Result.success(result) }
) )
@ -54,7 +55,7 @@ class RoomAliasResolverPresenterTest {
assertThat(awaitItem().resolveState.isLoading()).isTrue() assertThat(awaitItem().resolveState.isLoading()).isTrue()
val resultState = awaitItem() val resultState = awaitItem()
assertThat(resultState.roomAlias).isEqualTo(A_ROOM_ALIAS) assertThat(resultState.roomAlias).isEqualTo(A_ROOM_ALIAS)
assertThat(resultState.resolveState.dataOrNull()).isEqualTo(result) assertThat(resultState.resolveState.dataOrNull()).isEqualTo(result.get())
} }
} }

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.roomcall.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(libs.androidx.compose.ui.tooling.preview)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.api
import androidx.compose.runtime.Immutable
import io.element.android.features.roomcall.api.RoomCallState.OnGoing
import io.element.android.features.roomcall.api.RoomCallState.StandBy
@Immutable
sealed interface RoomCallState {
data class StandBy(
val canStartCall: Boolean,
) : RoomCallState
data class OnGoing(
val canJoinCall: Boolean,
val isUserInTheCall: Boolean,
val isUserLocallyInTheCall: Boolean,
) : RoomCallState
}
fun RoomCallState.hasPermissionToJoin() = when (this) {
is StandBy -> canStartCall
is OnGoing -> canJoinCall
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class RoomCallStateProvider : PreviewParameterProvider<RoomCallState> {
override val values: Sequence<RoomCallState> = sequenceOf(
aStandByCallState(),
aStandByCallState(canStartCall = false),
anOngoingCallState(),
anOngoingCallState(canJoinCall = false),
anOngoingCallState(canJoinCall = true, isUserInTheCall = true),
)
}
fun anOngoingCallState(
canJoinCall: Boolean = true,
isUserInTheCall: Boolean = false,
isUserLocallyInTheCall: Boolean = isUserInTheCall,
) = RoomCallState.OnGoing(
canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall,
isUserLocallyInTheCall = isUserLocallyInTheCall,
)
fun aStandByCallState(
canStartCall: Boolean = true,
) = RoomCallState.StandBy(
canStartCall = canStartCall,
)

View file

@ -0,0 +1,38 @@
import extension.setupAnvil
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.roomcall.impl"
}
setupAnvil()
dependencies {
api(projects.features.roomcall.api)
implementation(libs.kotlinx.collections.immutable)
implementation(projects.features.call.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.call.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -0,0 +1,53 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.ui.room.canCall
import javax.inject.Inject
class RoomCallStatePresenter @Inject constructor(
private val room: MatrixRoom,
private val currentCallService: CurrentCallService,
) : Presenter<RoomCallState> {
@Composable
override fun present(): RoomCallState {
val roomInfo by room.roomInfoFlow.collectAsState(null)
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
val isUserInTheCall by remember {
derivedStateOf {
room.sessionId in roomInfo?.activeRoomCallParticipants.orEmpty()
}
}
val currentCall by currentCallService.currentCall.collectAsState()
val isUserLocallyInTheCall by remember {
derivedStateOf {
(currentCall as? CurrentCall.RoomCall)?.roomId == room.roomId
}
}
val callState = when {
roomInfo?.hasRoomCall == true -> RoomCallState.OnGoing(
canJoinCall = canJoinCall,
isUserInTheCall = isUserInTheCall,
isUserLocallyInTheCall = isUserLocallyInTheCall,
)
else -> RoomCallState.StandBy(canStartCall = canJoinCall)
}
return callState
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.impl.RoomCallStatePresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
@ContributesTo(RoomScope::class)
@Module
interface RoomCallModule {
@Binds
fun bindRoomCallStatePresenter(presenter: RoomCallStatePresenter): Presenter<RoomCallState>
}

View file

@ -0,0 +1,201 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.roomcall.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import io.element.android.features.call.test.FakeCurrentCallService
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomCallStatePresenterTest {
@Test
fun `present - initial state`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(false) },
)
val presenter = createRoomCallStatePresenter(matrixRoom = room)
presenter.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
RoomCallState.StandBy(
canStartCall = false,
)
)
}
}
@Test
fun `present - initial state - user can join call`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(true) },
)
val presenter = createRoomCallStatePresenter(matrixRoom = room)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
RoomCallState.StandBy(
canStartCall = true,
)
)
}
}
@Test
fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(false) },
).apply {
givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
val presenter = createRoomCallStatePresenter(matrixRoom = room)
presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = false,
isUserInTheCall = false,
isUserLocallyInTheCall = false,
)
)
}
}
@Test
fun `present - user has joined the call on another session`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
)
)
}
val presenter = createRoomCallStatePresenter(matrixRoom = room)
presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = true,
isUserInTheCall = true,
isUserLocallyInTheCall = false,
)
)
}
}
@Test
fun `present - user has joined the call locally`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
)
)
}
val presenter = createRoomCallStatePresenter(
matrixRoom = room,
currentCallService = FakeCurrentCallService(MutableStateFlow(CurrentCall.RoomCall(room.roomId))),
)
presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = true,
isUserInTheCall = true,
isUserLocallyInTheCall = true,
)
)
}
}
@Test
fun `present - user leaves the call`() = runTest {
val room = FakeMatrixRoom(
canUserJoinCallResult = { Result.success(true) },
).apply {
givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = listOf(sessionId),
)
)
}
val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.RoomCall(room.roomId))
val currentCallService = FakeCurrentCallService(currentCall = currentCall)
val presenter = createRoomCallStatePresenter(
matrixRoom = room,
currentCallService = currentCallService
)
presenter.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = true,
isUserInTheCall = true,
isUserLocallyInTheCall = true,
)
)
currentCall.value = CurrentCall.None
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = true,
isUserInTheCall = true,
isUserLocallyInTheCall = false,
)
)
room.givenRoomInfo(
aRoomInfo(
hasRoomCall = true,
activeRoomCallParticipants = emptyList(),
)
)
assertThat(awaitItem()).isEqualTo(
RoomCallState.OnGoing(
canJoinCall = true,
isUserInTheCall = false,
isUserLocallyInTheCall = false,
)
)
room.givenRoomInfo(
aRoomInfo(
hasRoomCall = false,
activeRoomCallParticipants = emptyList(),
)
)
assertThat(awaitItem()).isEqualTo(
RoomCallState.StandBy(
canStartCall = true,
)
)
}
}
private fun createRoomCallStatePresenter(
matrixRoom: MatrixRoom,
currentCallService: CurrentCallService = FakeCurrentCallService(),
): RoomCallStatePresenter {
return RoomCallStatePresenter(
room = matrixRoom,
currentCallService = currentCallService,
)
}
}

View file

@ -49,6 +49,7 @@ dependencies {
implementation(projects.services.analytics.compose) implementation(projects.services.analytics.compose)
implementation(projects.features.poll.api) implementation(projects.features.poll.api)
implementation(projects.features.messages.api) implementation(projects.features.messages.api)
implementation(projects.features.roomcall.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View file

@ -21,6 +21,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled import io.element.android.features.messages.api.pinned.IsPinnedMessagesFeatureEnabled
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
@ -37,7 +38,6 @@ import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
@ -57,6 +57,7 @@ class RoomDetailsPresenter @Inject constructor(
private val notificationSettingsService: NotificationSettingsService, private val notificationSettingsService: NotificationSettingsService,
private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory, private val roomMembersDetailsPresenterFactory: RoomMemberDetailsPresenter.Factory,
private val leaveRoomPresenter: Presenter<LeaveRoomState>, private val leaveRoomPresenter: Presenter<LeaveRoomState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled, private val isPinnedMessagesFeatureEnabled: IsPinnedMessagesFeatureEnabled,
@ -87,18 +88,16 @@ class RoomDetailsPresenter @Inject constructor(
} }
} }
val syncUpdateTimestamp by room.syncUpdateFlow.collectAsState()
val membersState by room.membersStateFlow.collectAsState() val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState) val canInvite by getCanInvite(membersState)
val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME) val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME)
val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR) val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR)
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC) val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
val dmMember by room.getDirectRoomMember(membersState) val dmMember by room.getDirectRoomMember(membersState)
val currentMember by room.getCurrentRoomMember(membersState) val currentMember by room.getCurrentRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember, currentMember) val roomType by getRoomType(dmMember, currentMember)
val roomCallState = roomCallStatePresenter.present()
val topicState = remember(canEditTopic, roomTopic, roomType) { val topicState = remember(canEditTopic, roomTopic, roomType) {
val topic = roomTopic val topic = roomTopic
@ -143,7 +142,7 @@ class RoomDetailsPresenter @Inject constructor(
canInvite = canInvite, canInvite = canInvite,
canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room, canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room,
canShowNotificationSettings = canShowNotificationSettings.value, canShowNotificationSettings = canShowNotificationSettings.value,
canCall = canJoinCall, roomCallState = roomCallState,
roomType = roomType, roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,

View file

@ -9,6 +9,7 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -31,7 +32,7 @@ data class RoomDetailsState(
val canEdit: Boolean, val canEdit: Boolean,
val canInvite: Boolean, val canInvite: Boolean,
val canShowNotificationSettings: Boolean, val canShowNotificationSettings: Boolean,
val canCall: Boolean, val roomCallState: RoomCallState,
val leaveRoomState: LeaveRoomState, val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?, val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean, val isFavorite: Boolean,

View file

@ -10,6 +10,8 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
@ -42,7 +44,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed // Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true) roomNotificationSettings = aRoomNotificationSettings(mode = RoomNotificationMode.ALL_MESSAGES, isDefault = true)
), ),
aRoomDetailsState(canCall = false, canInvite = false), aRoomDetailsState(roomCallState = aStandByCallState(false), canInvite = false),
aRoomDetailsState(isPublic = false), aRoomDetailsState(isPublic = false),
aRoomDetailsState(heroes = aMatrixUserList()), aRoomDetailsState(heroes = aMatrixUserList()),
aRoomDetailsState(pinnedMessagesCount = 3), aRoomDetailsState(pinnedMessagesCount = 3),
@ -89,7 +91,7 @@ fun aRoomDetailsState(
canInvite: Boolean = false, canInvite: Boolean = false,
canEdit: Boolean = false, canEdit: Boolean = false,
canShowNotificationSettings: Boolean = true, canShowNotificationSettings: Boolean = true,
canCall: Boolean = true, roomCallState: RoomCallState = aStandByCallState(),
roomType: RoomDetailsType = RoomDetailsType.Room, roomType: RoomDetailsType = RoomDetailsType.Room,
roomMemberDetailsState: UserProfileState? = null, roomMemberDetailsState: UserProfileState? = null,
leaveRoomState: LeaveRoomState = aLeaveRoomState(), leaveRoomState: LeaveRoomState = aLeaveRoomState(),
@ -112,7 +114,7 @@ fun aRoomDetailsState(
canInvite = canInvite, canInvite = canInvite,
canEdit = canEdit, canEdit = canEdit,
canShowNotificationSettings = canShowNotificationSettings, canShowNotificationSettings = canShowNotificationSettings,
canCall = canCall, roomCallState = roomCallState,
roomType = roomType, roomType = roomType,
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,

View file

@ -42,6 +42,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomcall.api.hasPermissionToJoin
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
@ -299,7 +300,8 @@ private fun MainActionsSection(
) )
} }
} }
if (state.canCall) { if (state.roomCallState.hasPermissionToJoin()) {
// TODO Improve the view depending on all the cases here?
MainActionButton( MainActionButton(
title = stringResource(CommonStrings.action_call), title = stringResource(CommonStrings.action_call),
imageVector = CompoundIcons.VideoCall(), imageVector = CompoundIcons.VideoCall(),

View file

@ -17,6 +17,7 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.aMatrixRoom import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
@ -98,6 +99,7 @@ class RoomDetailsPresenterTest {
notificationSettingsService = matrixClient.notificationSettingsService(), notificationSettingsService = matrixClient.notificationSettingsService(),
roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory, roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
leaveRoomPresenter = { leaveRoomState }, leaveRoomPresenter = { leaveRoomState },
roomCallStatePresenter = { aStandByCallState() },
dispatchers = dispatchers, dispatchers = dispatchers,
isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled }, isPinnedMessagesFeatureEnabled = { isPinnedMessagesFeatureEnabled },
analyticsService = analyticsService, analyticsService = analyticsService,

View file

@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
private const val SEARCH_BATCH_SIZE = 20
class RoomDirectoryPresenter @Inject constructor( class RoomDirectoryPresenter @Inject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val roomDirectoryService: RoomDirectoryService, private val roomDirectoryService: RoomDirectoryService,
@ -51,7 +53,7 @@ class RoomDirectoryPresenter @Inject constructor(
loadingMore = false loadingMore = false
// debounce search query // debounce search query
delay(300) delay(300)
roomDirectoryList.filter(searchQuery, 20) roomDirectoryList.filter(filter = searchQuery, batchSize = SEARCH_BATCH_SIZE, viaServerName = null)
} }
LaunchedEffect(loadingMore) { LaunchedEffect(loadingMore) {
if (loadingMore) { if (loadingMore) {

View file

@ -81,7 +81,7 @@ import org.junit.Test
@Test @Test
fun `present - emit search event`() = runTest { fun `present - emit search event`() = runTest {
val filterLambda = lambdaRecorder { _: String?, _: Int -> val filterLambda = lambdaRecorder { _: String?, _: Int, _: String? ->
Result.success(Unit) Result.success(Unit)
} }
val roomDirectoryList = FakeRoomDirectoryList(filterLambda = filterLambda) val roomDirectoryList = FakeRoomDirectoryList(filterLambda = filterLambda)
@ -99,7 +99,7 @@ import org.junit.Test
} }
assert(filterLambda) assert(filterLambda)
.isCalledOnce() .isCalledOnce()
.with(value("test"), any()) .with(value("test"), any(), value(null))
} }
@Test @Test

View file

@ -46,7 +46,7 @@ class DefaultShareService @Inject constructor(
PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS
) )
.activities .activities
.firstOrNull { it.name.endsWith(".ShareActivity") } ?.firstOrNull { it.name.endsWith(".ShareActivity") }
?.let { shareActivityInfo -> ?.let { shareActivityInfo ->
ComponentName( ComponentName(
shareActivityInfo.packageName, shareActivityInfo.packageName,

View file

@ -80,14 +80,14 @@ fun IncomingVerificationView(
@Composable @Composable
private fun IncomingVerificationHeader(step: Step) { private fun IncomingVerificationHeader(step: Step) {
val iconStyle = when (step) { val iconStyle = when (step) {
Step.Canceled, Step.Canceled -> BigIcon.Style.AlertSolid
is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid()) is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction()) is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
Step.Completed -> BigIcon.Style.SuccessSolid Step.Completed -> BigIcon.Style.SuccessSolid
Step.Failure -> BigIcon.Style.AlertSolid Step.Failure -> BigIcon.Style.AlertSolid
} }
val titleTextId = when (step) { val titleTextId = when (step) {
Step.Canceled -> CommonStrings.common_verification_cancelled Step.Canceled -> R.string.screen_session_verification_request_failure_title
is Step.Initial -> R.string.screen_session_verification_request_title is Step.Initial -> R.string.screen_session_verification_request_title
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_title
@ -97,7 +97,7 @@ private fun IncomingVerificationHeader(step: Step) {
Step.Failure -> R.string.screen_session_verification_request_failure_title Step.Failure -> R.string.screen_session_verification_request_failure_title
} }
val subtitleTextId = when (step) { val subtitleTextId = when (step) {
Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle Step.Canceled -> R.string.screen_session_verification_request_failure_subtitle
is Step.Initial -> R.string.screen_session_verification_request_subtitle is Step.Initial -> R.string.screen_session_verification_request_subtitle
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle is SessionVerificationData.Decimals -> R.string.screen_session_verification_compare_numbers_subtitle

View file

@ -104,6 +104,7 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
fun handleEvents(event: VerifySelfSessionViewEvents) { fun handleEvents(event: VerifySelfSessionViewEvents) {
Timber.d("Verification user action: ${event::class.simpleName}") Timber.d("Verification user action: ${event::class.simpleName}")
when (event) { when (event) {
VerifySelfSessionViewEvents.UseAnotherDevice -> stateAndDispatch.dispatchAction(StateMachineEvent.UseAnotherDevice)
VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification) VerifySelfSessionViewEvents.RequestVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification) VerifySelfSessionViewEvents.StartSasVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge) VerifySelfSessionViewEvents.ConfirmVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.AcceptChallenge)
@ -134,6 +135,9 @@ class VerifySelfSessionPresenter @AssistedInject constructor(
isLastDevice = encryptionService.isLastDevice.value isLastDevice = encryptionService.isLastDevice.value
) )
} }
VerifySelfSessionStateMachine.State.UseAnotherDevice -> {
VerifySelfSessionState.Step.UseAnotherDevice
}
StateMachineState.RequestingVerification, StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification, StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted, StateMachineState.SasVerificationStarted,

View file

@ -26,6 +26,7 @@ data class VerifySelfSessionState(
// FIXME canEnterRecoveryKey value is never read. // FIXME canEnterRecoveryKey value is never read.
data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step data class Initial(val canEnterRecoveryKey: Boolean, val isLastDevice: Boolean = false) : Step
data object UseAnotherDevice : Step
data object Canceled : Step data object Canceled : Step
data object AwaitingOtherDeviceResponse : Step data object AwaitingOtherDeviceResponse : Step
data object Ready : Step data object Ready : Step

View file

@ -38,12 +38,14 @@ class VerifySelfSessionStateMachine @Inject constructor(
init { init {
spec { spec {
inState<State.Initial> { inState<State.Initial> {
on { _: Event.UseAnotherDevice, state ->
state.override { State.UseAnotherDevice.andLogStateChange() }
}
}
inState<State.UseAnotherDevice> {
on { _: Event.RequestVerification, state -> on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification.andLogStateChange() } state.override { State.RequestingVerification.andLogStateChange() }
} }
on { _: Event.StartSasVerification, state ->
state.override { State.StartingSasVerification.andLogStateChange() }
}
} }
inState<State.RequestingVerification> { inState<State.RequestingVerification> {
onEnterEffect { onEnterEffect {
@ -64,9 +66,6 @@ class VerifySelfSessionStateMachine @Inject constructor(
} }
} }
inState<State.Canceled> { inState<State.Canceled> {
on { _: Event.RequestVerification, state ->
state.override { State.RequestingVerification.andLogStateChange() }
}
on { _: Event.Reset, state -> on { _: Event.Reset, state ->
state.override { State.Initial.andLogStateChange() } state.override { State.Initial.andLogStateChange() }
} }
@ -119,6 +118,7 @@ class VerifySelfSessionStateMachine @Inject constructor(
on { _: Event.Cancel, state: MachineState<State> -> on { _: Event.Cancel, state: MachineState<State> ->
when (state.snapshot) { when (state.snapshot) {
State.Initial, State.Completed, State.Canceled -> state.noChange() State.Initial, State.Completed, State.Canceled -> state.noChange()
State.UseAnotherDevice -> state.override { State.Initial.andLogStateChange() }
// For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from // For some reason `cancelVerification` is not calling its delegate `didCancel` method so we don't pass from
// `Canceling` state to `Canceled` automatically anymore // `Canceling` state to `Canceled` automatically anymore
else -> { else -> {
@ -144,6 +144,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
/** The initial state, before verification started. */ /** The initial state, before verification started. */
data object Initial : State data object Initial : State
/** Let the user know that they need to get ready on their other session. */
data object UseAnotherDevice : State
/** Waiting for verification acceptance. */ /** Waiting for verification acceptance. */
data object RequestingVerification : State data object RequestingVerification : State
@ -175,6 +178,9 @@ class VerifySelfSessionStateMachine @Inject constructor(
} }
sealed interface Event { sealed interface Event {
/** User wants to use another session. */
data object UseAnotherDevice : Event
/** Request verification. */ /** Request verification. */
data object RequestVerification : Event data object RequestVerification : Event

View file

@ -56,6 +56,9 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
aVerifySelfSessionState( aVerifySelfSessionState(
step = Step.Skipped step = Step.Skipped
), ),
aVerifySelfSessionState(
step = Step.UseAnotherDevice
),
// Add other state here // Add other state here
) )
} }

View file

@ -66,7 +66,9 @@ fun VerifySelfSessionView(
fun cancelOrResetFlow() { fun cancelOrResetFlow() {
when (step) { when (step) {
is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset) is Step.Canceled -> state.eventSink(VerifySelfSessionViewEvents.Reset)
is Step.AwaitingOtherDeviceResponse, Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel) is Step.AwaitingOtherDeviceResponse,
Step.UseAnotherDevice,
Step.Ready -> state.eventSink(VerifySelfSessionViewEvents.Cancel)
is Step.Verifying -> { is Step.Verifying -> {
if (!step.state.isLoading()) { if (!step.state.isLoading()) {
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification) state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
@ -159,7 +161,9 @@ fun VerifySelfSessionView(
private fun VerifySelfSessionHeader(step: Step) { private fun VerifySelfSessionHeader(step: Step) {
val iconStyle = when (step) { val iconStyle = when (step) {
Step.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is Step.Initial, Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.LockSolid()) is Step.Initial -> BigIcon.Style.Default(CompoundIcons.LockSolid())
Step.UseAnotherDevice -> BigIcon.Style.Default(CompoundIcons.Devices())
Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Default(CompoundIcons.Devices())
Step.Canceled -> BigIcon.Style.AlertSolid Step.Canceled -> BigIcon.Style.AlertSolid
Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction()) Step.Ready, is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.Reaction())
Step.Completed -> BigIcon.Style.SuccessSolid Step.Completed -> BigIcon.Style.SuccessSolid
@ -167,8 +171,10 @@ private fun VerifySelfSessionHeader(step: Step) {
} }
val titleTextId = when (step) { val titleTextId = when (step) {
Step.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_title is Step.Initial -> R.string.screen_identity_confirmation_title
Step.Canceled -> CommonStrings.common_verification_cancelled Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_title
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_title
Step.Canceled -> CommonStrings.common_verification_failed
Step.Ready -> R.string.screen_session_verification_compare_emojis_title Step.Ready -> R.string.screen_session_verification_compare_emojis_title
Step.Completed -> R.string.screen_identity_confirmed_title Step.Completed -> R.string.screen_identity_confirmed_title
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
@ -179,8 +185,10 @@ private fun VerifySelfSessionHeader(step: Step) {
} }
val subtitleTextId = when (step) { val subtitleTextId = when (step) {
Step.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is Step.Initial, Step.AwaitingOtherDeviceResponse -> R.string.screen_identity_confirmation_subtitle is Step.Initial -> R.string.screen_identity_confirmation_subtitle
Step.Canceled -> R.string.screen_session_verification_cancelled_subtitle Step.UseAnotherDevice -> R.string.screen_session_verification_use_another_device_subtitle
Step.AwaitingOtherDeviceResponse -> R.string.screen_session_verification_waiting_another_device_subtitle
Step.Canceled -> R.string.screen_session_verification_failed_subtitle
Step.Ready -> R.string.screen_session_verification_ready_subtitle Step.Ready -> R.string.screen_session_verification_ready_subtitle
Step.Completed -> R.string.screen_identity_confirmed_subtitle Step.Completed -> R.string.screen_identity_confirmed_subtitle
is Step.Verifying -> when (step.data) { is Step.Verifying -> when (step.data) {
@ -248,25 +256,18 @@ private fun VerifySelfSessionBottomMenu(
Step.Loading -> error("Should not happen") Step.Loading -> error("Should not happen")
is Step.Initial -> { is Step.Initial -> {
VerificationBottomMenu { VerificationBottomMenu {
if (verificationViewState.isLastDevice) { if (verificationViewState.isLastDevice.not()) {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
onClick = onEnterRecoveryKey,
)
} else {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_use_another_device), text = stringResource(R.string.screen_identity_use_another_device),
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, onClick = { eventSink(VerifySelfSessionViewEvents.UseAnotherDevice) },
)
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
onClick = onEnterRecoveryKey,
) )
} }
// This option should always be displayed Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
onClick = onEnterRecoveryKey,
)
OutlinedButton( OutlinedButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm), text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
@ -274,18 +275,26 @@ private fun VerifySelfSessionBottomMenu(
) )
} }
} }
is Step.UseAnotherDevice -> {
VerificationBottomMenu {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_start_verification),
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
)
// Placeholder so the 1st button keeps its vertical position
Spacer(modifier = Modifier.height(40.dp))
}
}
is Step.Canceled -> { is Step.Canceled -> {
VerificationBottomMenu { VerificationBottomMenu {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_session_verification_positive_button_canceled), text = stringResource(CommonStrings.action_done),
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancelClick, onClick = onCancelClick,
) )
// Placeholder so the 1st button keeps its vertical position
Spacer(modifier = Modifier.height(40.dp))
} }
} }
is Step.Ready -> { is Step.Ready -> {
@ -309,6 +318,7 @@ private fun VerifySelfSessionBottomMenu(
text = stringResource(R.string.screen_identity_waiting_on_other_device), text = stringResource(R.string.screen_identity_waiting_on_other_device),
onClick = {}, onClick = {},
showProgress = true, showProgress = true,
enabled = false,
) )
// Placeholder so the 1st button keeps its vertical position // Placeholder so the 1st button keeps its vertical position
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(40.dp))

View file

@ -8,6 +8,7 @@
package io.element.android.features.verifysession.impl.outgoing package io.element.android.features.verifysession.impl.outgoing
sealed interface VerifySelfSessionViewEvents { sealed interface VerifySelfSessionViewEvents {
data object UseAnotherDevice : VerifySelfSessionViewEvents
data object RequestVerification : VerifySelfSessionViewEvents data object RequestVerification : VerifySelfSessionViewEvents
data object StartSasVerification : VerifySelfSessionViewEvents data object StartSasVerification : VerifySelfSessionViewEvents
data object ConfirmVerification : VerifySelfSessionViewEvents data object ConfirmVerification : VerifySelfSessionViewEvents

View file

@ -35,6 +35,10 @@
<string name="screen_session_verification_request_title">"Verification requested"</string> <string name="screen_session_verification_request_title">"Verification requested"</string>
<string name="screen_session_verification_they_dont_match">"They dont match"</string> <string name="screen_session_verification_they_dont_match">"They dont match"</string>
<string name="screen_session_verification_they_match">"They match"</string> <string name="screen_session_verification_they_match">"They match"</string>
<string name="screen_session_verification_use_another_device_subtitle">"Make sure you have the app open in the other device before starting verification from here."</string>
<string name="screen_session_verification_use_another_device_title">"Open the app on another verified device"</string>
<string name="screen_session_verification_waiting_another_device_subtitle">"You should see a popup on the other device. Start the verification from there now."</string>
<string name="screen_session_verification_waiting_another_device_title">"Start verification on the other device"</string>
<string name="screen_session_verification_waiting_to_accept_subtitle">"Accept the request to start the verification process in your other session to continue."</string> <string name="screen_session_verification_waiting_to_accept_subtitle">"Accept the request to start the verification process in your other session to continue."</string>
<string name="screen_session_verification_waiting_to_accept_title">"Waiting to accept request"</string> <string name="screen_session_verification_waiting_to_accept_title">"Waiting to accept request"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string> <string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>

View file

@ -7,10 +7,7 @@
package io.element.android.features.verifysession.impl.outgoing package io.element.android.features.verifysession.impl.outgoing
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.LogoutUseCase import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.features.logout.test.FakeLogoutUseCase import io.element.android.features.logout.test.FakeLogoutUseCase
@ -33,6 +30,7 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -48,9 +46,7 @@ class VerifySelfSessionPresenterTest {
val presenter = createVerifySelfSessionPresenter( val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(), service = unverifiedSessionService(),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitItem().run { awaitItem().run {
assertThat(step).isEqualTo(Step.Initial(false)) assertThat(step).isEqualTo(Step.Initial(false))
assertThat(displaySkipButton).isTrue() assertThat(displaySkipButton).isTrue()
@ -65,9 +61,7 @@ class VerifySelfSessionPresenterTest {
service = unverifiedSessionService(), service = unverifiedSessionService(),
buildMeta = buildMeta, buildMeta = buildMeta,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
assertThat(awaitItem().displaySkipButton).isFalse() assertThat(awaitItem().displaySkipButton).isFalse()
} }
} }
@ -83,9 +77,7 @@ class VerifySelfSessionPresenterTest {
emitRecoveryState(RecoveryState.INCOMPLETE) emitRecoveryState(RecoveryState.INCOMPLETE)
} }
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
assertThat(awaitItem().step).isEqualTo(Step.Initial(true)) assertThat(awaitItem().step).isEqualTo(Step.Initial(true))
resetLambda.assertions().isCalledOnce().with(value(true)) resetLambda.assertions().isCalledOnce().with(value(true))
} }
@ -100,9 +92,7 @@ class VerifySelfSessionPresenterTest {
emitRecoveryState(RecoveryState.INCOMPLETE) emitRecoveryState(RecoveryState.INCOMPLETE)
} }
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true)) assertThat(awaitItem().step).isEqualTo(Step.Initial(canEnterRecoveryKey = true, isLastDevice = true))
} }
} }
@ -114,43 +104,17 @@ class VerifySelfSessionPresenterTest {
startVerificationLambda = { }, startVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
requestVerificationAndAwaitVerifyingState(service) requestVerificationAndAwaitVerifyingState(service)
} }
} }
@Test
fun `present - Handles startSasVerification`() = runTest {
val service = unverifiedSessionService(
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(Step.Initial(false))
initialState.eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response:
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
service.emitVerificationFlowState(VerificationFlowState.DidStartSasVerification)
// ChallengeReceived:
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
val verifyingState = awaitItem()
assertThat(verifyingState.step).isInstanceOf(Step.Verifying::class.java)
}
}
@Test @Test
fun `present - Cancellation on initial state does nothing`() = runTest { fun `present - Cancellation on initial state does nothing`() = runTest {
val presenter = createVerifySelfSessionPresenter( val presenter = createVerifySelfSessionPresenter(
service = unverifiedSessionService(), service = unverifiedSessionService(),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.step).isEqualTo(Step.Initial(false)) assertThat(initialState.step).isEqualTo(Step.Initial(false))
val eventSink = initialState.eventSink val eventSink = initialState.eventSink
@ -167,9 +131,7 @@ class VerifySelfSessionPresenterTest {
approveVerificationLambda = { }, approveVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification) state.eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
// Cancelling // Cancelling
@ -186,9 +148,8 @@ class VerifySelfSessionPresenterTest {
requestVerificationLambda = { }, requestVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present() awaitItem().eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
}.test {
awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification) awaitItem().eventSink(VerifySelfSessionViewEvents.RequestVerification)
service.emitVerificationFlowState(VerificationFlowState.DidFail) service.emitVerificationFlowState(VerificationFlowState.DidFail)
assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java) assertThat(awaitItem().step).isInstanceOf(Step.AwaitingOtherDeviceResponse::class.java)
@ -204,9 +165,7 @@ class VerifySelfSessionPresenterTest {
cancelVerificationLambda = { }, cancelVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.Cancel) state.eventSink(VerifySelfSessionViewEvents.Cancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled) assertThat(awaitItem().step).isEqualTo(Step.Canceled)
@ -220,35 +179,13 @@ class VerifySelfSessionPresenterTest {
startVerificationLambda = { }, startVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
requestVerificationAndAwaitVerifyingState(service) requestVerificationAndAwaitVerifyingState(service)
service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList()))) service.emitVerificationFlowState(VerificationFlowState.DidReceiveVerificationData(SessionVerificationData.Emojis(emptyList())))
ensureAllEventsConsumed() ensureAllEventsConsumed()
} }
} }
@Test
fun `present - Restart after cancellation returns to requesting verification`() = runTest {
val service = unverifiedSessionService(
requestVerificationLambda = { },
startVerificationLambda = { },
)
val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service)
service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Went back to requesting verification
assertThat(awaitItem().step).isEqualTo(Step.AwaitingOtherDeviceResponse)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - Go back after cancellation returns to initial state`() = runTest { fun `present - Go back after cancellation returns to initial state`() = runTest {
val service = unverifiedSessionService( val service = unverifiedSessionService(
@ -256,9 +193,7 @@ class VerifySelfSessionPresenterTest {
startVerificationLambda = { }, startVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
service.emitVerificationFlowState(VerificationFlowState.DidCancel) service.emitVerificationFlowState(VerificationFlowState.DidCancel)
assertThat(awaitItem().step).isEqualTo(Step.Canceled) assertThat(awaitItem().step).isEqualTo(Step.Canceled)
@ -280,9 +215,7 @@ class VerifySelfSessionPresenterTest {
approveVerificationLambda = { }, approveVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState( val state = requestVerificationAndAwaitVerifyingState(
service, service,
SessionVerificationData.Emojis(emojis) SessionVerificationData.Emojis(emojis)
@ -307,9 +240,7 @@ class VerifySelfSessionPresenterTest {
declineVerificationLambda = { }, declineVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.DeclineVerification) state.eventSink(VerifySelfSessionViewEvents.DeclineVerification)
assertThat(awaitItem().step).isEqualTo( assertThat(awaitItem().step).isEqualTo(
@ -330,9 +261,7 @@ class VerifySelfSessionPresenterTest {
startVerificationLambda = { }, startVerificationLambda = { },
) )
val presenter = createVerifySelfSessionPresenter(service) val presenter = createVerifySelfSessionPresenter(service)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val state = requestVerificationAndAwaitVerifyingState(service) val state = requestVerificationAndAwaitVerifyingState(service)
state.eventSink(VerifySelfSessionViewEvents.SkipVerification) state.eventSink(VerifySelfSessionViewEvents.SkipVerification)
assertThat(awaitItem().step).isEqualTo(Step.Skipped) assertThat(awaitItem().step).isEqualTo(Step.Skipped)
@ -352,9 +281,7 @@ class VerifySelfSessionPresenterTest {
service = service, service = service,
showDeviceVerifiedScreen = true, showDeviceVerifiedScreen = true,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
assertThat(awaitItem().step).isEqualTo(Step.Completed) assertThat(awaitItem().step).isEqualTo(Step.Completed)
} }
} }
@ -372,9 +299,7 @@ class VerifySelfSessionPresenterTest {
service = service, service = service,
showDeviceVerifiedScreen = false, showDeviceVerifiedScreen = false,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
assertThat(awaitItem().step).isEqualTo(Step.Skipped) assertThat(awaitItem().step).isEqualTo(Step.Skipped)
} }
@ -394,9 +319,7 @@ class VerifySelfSessionPresenterTest {
service, service,
logoutUseCase = FakeLogoutUseCase(signOutLambda) logoutUseCase = FakeLogoutUseCase(signOutLambda)
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialItem = awaitItem() val initialItem = awaitItem()
initialItem.eventSink(VerifySelfSessionViewEvents.SignOut) initialItem.eventSink(VerifySelfSessionViewEvents.SignOut)
@ -414,6 +337,9 @@ class VerifySelfSessionPresenterTest {
): VerifySelfSessionState { ): VerifySelfSessionState {
var state = awaitItem() var state = awaitItem()
assertThat(state.step).isEqualTo(Step.Initial(false)) assertThat(state.step).isEqualTo(Step.Initial(false))
state.eventSink(VerifySelfSessionViewEvents.UseAnotherDevice)
state = awaitItem()
assertThat(state.step).isEqualTo(Step.UseAnotherDevice)
state.eventSink(VerifySelfSessionViewEvents.RequestVerification) state.eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response: // Await for other device response:
fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest) fakeService.emitVerificationFlowState(VerificationFlowState.DidAcceptVerificationRequest)

View file

@ -17,7 +17,7 @@ core = "1.13.1"
# due to the DefaultMigrationStore not behaving as expected. # due to the DefaultMigrationStore not behaving as expected.
# Stick to 1.0.0 for now, and ensure that this scenario cannot be reproduced when upgrading the version. # Stick to 1.0.0 for now, and ensure that this scenario cannot be reproduced when upgrading the version.
datastore = "1.0.0" datastore = "1.0.0"
constraintlayout = "2.1.4" constraintlayout = "2.2.0"
constraintlayout_compose = "1.1.0" constraintlayout_compose = "1.1.0"
lifecycle = "2.8.7" lifecycle = "2.8.7"
activity = "1.9.3" activity = "1.9.3"
@ -173,7 +173,7 @@ jsoup = "org.jsoup:jsoup:1.18.1"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.58" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.60"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -182,7 +182,7 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions",
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.4.0" sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0" unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.1" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
@ -237,7 +237,7 @@ ktlint = "org.jlleitschuh.gradle.ktlint:12.1.1"
dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12"
dependencycheck = "org.owasp.dependencycheck:10.0.4" dependencycheck = "org.owasp.dependencycheck:10.0.4"
dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" }
paparazzi = "app.cash.paparazzi:1.3.4" paparazzi = "app.cash.paparazzi:1.3.5"
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" } knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" }

View file

@ -95,7 +95,9 @@ class DefaultPinnedMessagesBannerFormatter @Inject constructor(
messageType.bestDescription.prefixWith(CommonStrings.common_audio) messageType.bestDescription.prefixWith(CommonStrings.common_audio)
} }
is VoiceMessageType -> { is VoiceMessageType -> {
messageType.bestDescription.prefixWith(CommonStrings.common_voice_message) // In this case, do not use bestDescription, because the filename is useless, only use the caption if available.
messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message))
?: sp.getString(CommonStrings.common_voice_message)
} }
is OtherMessageType -> { is OtherMessageType -> {
messageType.body messageType.body

View file

@ -110,25 +110,27 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
messageType.toPlainText(permalinkParser) messageType.toPlainText(permalinkParser)
} }
is VideoMessageType -> { is VideoMessageType -> {
sp.getString(CommonStrings.common_video) messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_video))
} }
is ImageMessageType -> { is ImageMessageType -> {
sp.getString(CommonStrings.common_image) messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_image))
} }
is StickerMessageType -> { is StickerMessageType -> {
sp.getString(CommonStrings.common_sticker) messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_sticker))
} }
is LocationMessageType -> { is LocationMessageType -> {
sp.getString(CommonStrings.common_shared_location) sp.getString(CommonStrings.common_shared_location)
} }
is FileMessageType -> { is FileMessageType -> {
sp.getString(CommonStrings.common_file) messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_file))
} }
is AudioMessageType -> { is AudioMessageType -> {
sp.getString(CommonStrings.common_audio) messageType.bestDescription.prefixWith(sp.getString(CommonStrings.common_audio))
} }
is VoiceMessageType -> { is VoiceMessageType -> {
sp.getString(CommonStrings.common_voice_message) // In this case, do not use bestDescription, because the filename is useless, only use the caption if available.
messageType.caption?.prefixWith(sp.getString(CommonStrings.common_voice_message))
?: sp.getString(CommonStrings.common_voice_message)
} }
is OtherMessageType -> { is OtherMessageType -> {
messageType.body messageType.body
@ -140,7 +142,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
return message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) return message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
} }
private fun String.prefixIfNeeded( private fun CharSequence.prefixIfNeeded(
senderDisambiguatedDisplayName: String, senderDisambiguatedDisplayName: String,
isDmRoom: Boolean, isDmRoom: Boolean,
isOutgoing: Boolean, isOutgoing: Boolean,

View file

@ -159,11 +159,11 @@ class DefaultPinnedMessagesBannerFormatterTest {
val expectedResult = when (type) { val expectedResult = when (type) {
is VideoMessageType, is VideoMessageType,
is AudioMessageType, is AudioMessageType,
is VoiceMessageType,
is ImageMessageType, is ImageMessageType,
is StickerMessageType, is StickerMessageType,
is FileMessageType, is FileMessageType,
is LocationMessageType -> AnnotatedString::class.java is LocationMessageType -> AnnotatedString::class.java
is VoiceMessageType,
is EmoteMessageType, is EmoteMessageType,
is TextMessageType, is TextMessageType,
is NoticeMessageType, is NoticeMessageType,
@ -176,7 +176,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
val expectedResult = when (type) { val expectedResult = when (type) {
is VideoMessageType -> "Video: Shared body" is VideoMessageType -> "Video: Shared body"
is AudioMessageType -> "Audio: Shared body" is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message: Shared body" is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image: Shared body" is ImageMessageType -> "Image: Shared body"
is StickerMessageType -> "Sticker: Shared body" is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body" is FileMessageType -> "File: Shared body"

View file

@ -208,32 +208,51 @@ class DefaultRoomLastMessageFormatterTest {
// Verify results of DM mode // Verify results of DM mode
for ((type, result) in resultsInDm) { for ((type, result) in resultsInDm) {
val string = result.toString()
val expectedResult = when (type) { val expectedResult = when (type) {
is VideoMessageType -> "Video" is VideoMessageType -> "Video: Shared body"
is AudioMessageType -> "Audio" is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message" is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image" is ImageMessageType -> "Image: Shared body"
is StickerMessageType -> "Sticker" is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File" is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location" is LocationMessageType -> "Shared location"
is EmoteMessageType -> "* $senderName ${type.body}" is EmoteMessageType -> "* $senderName ${type.body}"
is TextMessageType, is TextMessageType,
is NoticeMessageType, is NoticeMessageType,
is OtherMessageType -> body is OtherMessageType -> body
} }
assertWithMessage("$type was not properly handled for DM").that(result).isEqualTo(expectedResult) val shouldCreateAnnotatedString = when (type) {
is VideoMessageType -> true
is AudioMessageType -> true
is VoiceMessageType -> false
is ImageMessageType -> true
is StickerMessageType -> true
is FileMessageType -> true
is LocationMessageType -> false
is EmoteMessageType -> false
is TextMessageType -> false
is NoticeMessageType -> false
is OtherMessageType -> false
}
if (shouldCreateAnnotatedString) {
assertWithMessage("$type doesn't produce an AnnotatedString")
.that(result)
.isInstanceOf(AnnotatedString::class.java)
}
assertWithMessage("$type was not properly handled for DM").that(string).isEqualTo(expectedResult)
} }
// Verify results of Room mode // Verify results of Room mode
for ((type, result) in resultsInRoom) { for ((type, result) in resultsInRoom) {
val string = result.toString() val string = result.toString()
val expectedResult = when (type) { val expectedResult = when (type) {
is VideoMessageType -> "$expectedPrefix: Video" is VideoMessageType -> "$expectedPrefix: Video: Shared body"
is AudioMessageType -> "$expectedPrefix: Audio" is AudioMessageType -> "$expectedPrefix: Audio: Shared body"
is VoiceMessageType -> "$expectedPrefix: Voice message" is VoiceMessageType -> "$expectedPrefix: Voice message"
is ImageMessageType -> "$expectedPrefix: Image" is ImageMessageType -> "$expectedPrefix: Image: Shared body"
is StickerMessageType -> "$expectedPrefix: Sticker" is StickerMessageType -> "$expectedPrefix: Sticker: Shared body"
is FileMessageType -> "$expectedPrefix: File" is FileMessageType -> "$expectedPrefix: File: Shared body"
is LocationMessageType -> "$expectedPrefix: Shared location" is LocationMessageType -> "$expectedPrefix: Shared location"
is TextMessageType, is TextMessageType,
is NoticeMessageType, is NoticeMessageType,
@ -249,7 +268,8 @@ class DefaultRoomLastMessageFormatterTest {
is FileMessageType -> true is FileMessageType -> true
is LocationMessageType -> false is LocationMessageType -> false
is EmoteMessageType -> false is EmoteMessageType -> false
is TextMessageType, is NoticeMessageType -> true is TextMessageType -> true
is NoticeMessageType -> true
is OtherMessageType -> true is OtherMessageType -> true
} }
if (shouldCreateAnnotatedString) { if (shouldCreateAnnotatedString) {

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.featureflag.api
import io.element.android.appconfig.OnBoardingConfig import io.element.android.appconfig.OnBoardingConfig
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
/** /**
* To enable or disable a FeatureFlags, change the `defaultValue` value. * To enable or disable a FeatureFlags, change the `defaultValue` value.
@ -124,5 +125,19 @@ enum class FeatureFlags(
" You'll have to stop and re-open the app manually for that setting to take effect.", " You'll have to stop and re-open the app manually for that setting to take effect.",
defaultValue = { false }, defaultValue = { false },
isFinished = false, isFinished = false,
) ),
Knock(
key = "feature.knock",
title = "Ask to join",
description = "Allow creating rooms which users can request access to.",
defaultValue = { false },
isFinished = false,
),
MediaUploadOnSendQueue(
key = "feature.media_upload_through_send_queue",
title = "Media upload through send queue",
description = "Experimental support for treating media uploads as regular events, with an improved retry and cancellation implementation.",
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
isFinished = false,
),
} }

View file

@ -108,7 +108,14 @@ interface MatrixClient : Closeable {
suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit> suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit>
suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>>
suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<ResolvedRoomAlias>
/**
* Resolves the given room alias to a roomID (and a list of servers), if possible.
* @param roomAlias the room alias to resolve
* @return the resolved room alias if any, an empty result if not found,or an error if the resolution failed.
*
*/
suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<Optional<ResolvedRoomAlias>>
/** /**
* Enables or disables the sending queue, according to the given parameter. * Enables or disables the sending queue, according to the given parameter.

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.createroom package io.element.android.libraries.matrix.api.createroom
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import java.util.Optional
data class CreateRoomParameters( data class CreateRoomParameters(
val name: String?, val name: String?,
@ -18,4 +19,6 @@ data class CreateRoomParameters(
val preset: RoomPreset, val preset: RoomPreset,
val invite: List<UserId>? = null, val invite: List<UserId>? = null,
val avatar: String? = null, val avatar: String? = null,
val joinRuleOverride: JoinRuleOverride = JoinRuleOverride.None,
val canonicalAlias: Optional<String> = Optional.empty(),
) )

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.createroom
/**
* Rules to override the default room join rules.
*/
sealed interface JoinRuleOverride {
data object Knock : JoinRuleOverride
data object None : JoinRuleOverride
}

View file

@ -132,8 +132,8 @@ interface MatrixRoom : Closeable {
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
imageInfo: ImageInfo, imageInfo: ImageInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>
@ -141,8 +141,8 @@ interface MatrixRoom : Closeable {
file: File, file: File,
thumbnailFile: File?, thumbnailFile: File?,
videoInfo: VideoInfo, videoInfo: VideoInfo,
body: String?, caption: String?,
formattedBody: String?, formattedCaption: String?,
progressCallback: ProgressCallback? progressCallback: ProgressCallback?
): Result<MediaUploadHandler> ): Result<MediaUploadHandler>

Some files were not shown because too many files have changed in this diff Show more