Merge branch 'release/26.01.2' into main

This commit is contained in:
Benoit Marty 2026-01-28 16:42:02 +01:00
commit 790f5cd6b7
532 changed files with 6311 additions and 3920 deletions

View file

@ -36,7 +36,7 @@ jobs:
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy

View file

@ -23,7 +23,7 @@ jobs:
- name: Run SAS String script
run: ./tools/sas/import_sas_strings.py
- name: Create Pull Request for SAS Strings
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
commit-message: Sync SAS Strings
title: Sync SAS Strings

View file

@ -1,3 +1,26 @@
Changes in Element X v26.01.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.01.1 -->
## What's Changed
### 🐛 Bugfixes
* Ensure that log files are not too big. by @bmarty in https://github.com/element-hq/element-x-android/pull/6003
* Make the number view scrollable by @bmarty in https://github.com/element-hq/element-x-android/pull/6017
* Ensure that room with long names are rendered correctly in the room list. by @bmarty in https://github.com/element-hq/element-x-android/pull/6019
* Create `AppMigration09` to remove the cached `well-known` config from the SDK by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6026
### 🚧 In development 🚧
* [POC] Signin with Element Classic by @bmarty in https://github.com/element-hq/element-x-android/pull/6013
* Space : manage rooms by @ganfra in https://github.com/element-hq/element-x-android/pull/6022
### Dependency upgrades
* fix(deps): update dependency androidx.compose:compose-bom to v2026 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6010
* fix(deps): update dependency io.sentry:sentry-android to v8.30.0 - autoclosed by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6014
* fix(deps): update dependency com.google.firebase:firebase-bom to v34.8.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6018
* Upgrade androidx.biometric:biometric-ktx to 1.4.0-alpha02 by @bmarty in https://github.com/element-hq/element-x-android/pull/6020
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.01.0...v26.01.1
Changes in Element X v26.01.0
=============================

View file

@ -132,8 +132,23 @@ android {
optimization {
enable = true
keepRules {
files.add(File(projectDir, "proguard-rules.pro"))
files.add(File(projectDir, "common-proguard-rules.pro"))
files.add(getDefaultProguardFile("proguard-android-optimize.txt"))
// Depending on whether the app flavor is enterprise or not we want to use different proguard rules.
val flavorProguardFile = if (isEnterpriseBuild) {
// Custom rules for enterprise builds
File(projectDir, "enterprise-proguard-rules.pro")
} else {
// These default rules prevent the OSS app from being obfuscated
File(projectDir, "default-proguard-rules.pro")
}
if (flavorProguardFile.exists()) {
files.add(flavorProguardFile)
} else {
logger.warn("Proguard file ${flavorProguardFile.absolutePath} does not exist")
}
}
}
}

View file

@ -5,6 +5,9 @@
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Increase optimizations passes to 3
-optimizationpasses 3
# JNA
-dontwarn java.awt.*
-keep class com.sun.jna.** { *; }
@ -41,7 +44,6 @@
static int windowAttachCount(android.view.View);
}
# Keep LogSessionId class and related classes (https://github.com/androidx/media/issues/2535)
-keep class android.media.metrics.LogSessionId { *; }
-keep class android.media.metrics.** { *; }
@ -66,10 +68,9 @@
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
# Also needed after AGP 8.13.1 upgrade, it seems like proguard is now more aggressive on removing unused code
-keep,allowshrinking class org.matrix.rustcomponents.sdk.** { *;}
-keep,allowshrinking class uniffi.** { *;}
-keep,allowshrinking class io.element.android.x.di.** { *; }
-keepclasseswithmembernames,allowoptimization,allowshrinking class io.element.android.** { *; }
-keep,allowoptimization,allowshrinking class org.matrix.rustcomponents.sdk.** { *;}
-keep,allowoptimization,allowshrinking class uniffi.** { *;}
-keep,allowoptimization,allowshrinking class io.element.android.x.di.** { *; }
# Keep Metro classes
-keep,allowshrinking class dev.zacsweers.metro.** { *; }
-keep,allowoptimization,allowshrinking class dev.zacsweers.metro.** { *; }

View file

@ -0,0 +1,2 @@
# Don't obfuscate anything for non-enterprise builds
-dontobfuscate

View file

@ -30,4 +30,9 @@ object RageshakeConfig {
* The maximum size of a single log file.
*/
const val MAX_LOG_CONTENT_SIZE = 100 * 1024 * 1024L
/**
* The maximum number of log lines a rageshake can contain.
*/
const val MAX_LOG_LINES_SIZE = 1_000_000
}

View file

@ -484,7 +484,10 @@ class LoggedInFlowNode(
backstack.replace(NavTarget.Room(roomIdOrAlias = RoomIdOrAlias.Id(roomId), serverNames = emptyList()))
}
}
createRoomEntryPoint.createNode(isSpace = true, parentNode = this, buildContext = buildContext, callback = callback)
createRoomEntryPoint
.builder(parentNode = this, buildContext = buildContext, callback = callback)
.setIsSpace(true)
.build()
}
is NavTarget.SecureBackup -> {
secureBackupEntryPoint.createNode(

View file

@ -214,6 +214,10 @@ class RoomFlowNode(
)
}
is NavTarget.JoinRoom -> {
// Clear analytics transactions for opening a joined room, since we're display a non-joined one
analyticsService.removeLongRunningTransaction(LoadJoinedRoomFlow)
analyticsService.removeLongRunningTransaction(OpenRoom)
val inputs = JoinRoomEntryPoint.Inputs(
roomId = navTarget.roomId,
roomIdOrAlias = inputs.roomIdOrAlias,

View file

@ -41,6 +41,10 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -53,6 +57,7 @@ class JoinedRoomFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory,
private val analyticsService: AnalyticsService,
) :
BaseFlowNode<JoinedRoomFlowNode.NavTarget>(
backstack = BackStack(
@ -81,6 +86,11 @@ class JoinedRoomFlowNode(
override fun onBuilt() {
super.onBuilt()
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
loadingRoomStateStateFlow
.map {
it is LoadingRoomState.Loaded

View file

@ -48,8 +48,6 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
@ -106,8 +104,6 @@ class JoinedRoomLoadedFlowNode(
init {
lifecycle.subscribe(
onCreate = {
val parent = analyticsService.getLongRunningTransaction(OpenRoom)
analyticsService.startLongRunningTransaction(LoadMessagesUi, parent)
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room)

View file

@ -15,9 +15,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.api.crash.CrashDetectionEvent
import io.element.android.features.rageshake.api.crash.CrashDetectionView
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvent
import io.element.android.features.rageshake.api.detection.RageshakeDetectionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -39,8 +39,8 @@ fun RootView(
children()
fun onOpenBugReport() {
state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed)
state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss)
state.crashDetectionState.eventSink(CrashDetectionEvent.ResetAppHasCrashed)
state.rageshakeDetectionState.eventSink(RageshakeDetectionEvent.Dismiss)
onOpenBugReport.invoke()
}

View file

@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.5.3")
detektPlugins("io.nlopez.compose.rules:detekt:0.5.6")
detektPlugins(project(":tests:detekt-rules"))
}

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_space_announcement_item1">"Просмотр пространств, которые вы создали или к которым присоединились"</string>
<string name="screen_space_announcement_item1">"Просмотреть пространства, которые вы создали или к которым присоединились"</string>
<string name="screen_space_announcement_item2">"Принимать или отклонять приглашения в пространства"</string>
<string name="screen_space_announcement_item3">"Откройте для себя все комнаты, к которым вы можете присоединиться в своих пространствах."</string>
<string name="screen_space_announcement_item4">"Присоединиться к публичному пространству"</string>
<string name="screen_space_announcement_item3">"Найти все комнаты, к которым можно присоединиться в ваших пространствах"</string>
<string name="screen_space_announcement_item4">"Присоединяться к публичным пространствам"</string>
<string name="screen_space_announcement_item5">"Покинуть все пространства, к которым вы присоединились"</string>
<string name="screen_space_announcement_notice">"Работа с пространствами скоро станет доступна"</string>
<string name="screen_space_announcement_subtitle">"Добро пожаловать в бета-версию Spaces! В этой первой версии вы сможете:"</string>
<string name="screen_space_announcement_subtitle">"Добро пожаловать в бета-версию пространств! В этой первой версии вы сможете:"</string>
<string name="screen_space_announcement_title">"Представляем пространства"</string>
</resources>

View file

@ -15,12 +15,13 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : FeatureEntryPoint {
fun createNode(
isSpace: Boolean,
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
interface Builder {
fun setIsSpace(isSpace: Boolean): Builder
fun setParentSpace(parentSpaceId: RoomId): Builder
fun build(): Node
}
fun builder(parentNode: Node, buildContext: BuildContext, callback: Callback): Builder
interface Callback : Plugin {
fun onRoomCreated(roomId: RoomId)

View file

@ -38,6 +38,7 @@ dependencies {
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.previewutils)
implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)

View file

@ -38,7 +38,7 @@ class CreateRoomFlowNode(
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<CreateRoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.ConfigureRoom(isSpace = plugins.filterIsInstance<Inputs>().first().isSpace),
initialElement = initialElementFromInputs(plugins.filterIsInstance<Inputs>().first()),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@ -46,7 +46,8 @@ class CreateRoomFlowNode(
) {
@Parcelize
data class Inputs(
val isSpace: Boolean
val isSpace: Boolean,
val parentSpaceId: RoomId?,
) : NodeInputs, Parcelable
private val callback: CreateRoomEntryPoint.Callback = callback()
@ -54,7 +55,7 @@ class CreateRoomFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.ConfigureRoom -> {
val inputs = ConfigureRoomNode.Inputs(isSpace = navTarget.isSpace)
val inputs = ConfigureRoomNode.Inputs(isSpace = navTarget.isSpace, parentSpaceId = navTarget.parentSpaceId)
val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) {
backstack.replace(NavTarget.AddPeople(roomId))
@ -81,9 +82,14 @@ class CreateRoomFlowNode(
sealed interface NavTarget : Parcelable {
@Parcelize
data class ConfigureRoom(val isSpace: Boolean) : NavTarget
data class ConfigureRoom(val isSpace: Boolean, val parentSpaceId: RoomId?) : NavTarget
@Parcelize
data class AddPeople(val roomId: RoomId) : NavTarget
}
}
private fun initialElementFromInputs(inputs: CreateRoomFlowNode.Inputs) = CreateRoomFlowNode.NavTarget.ConfigureRoom(
isSpace = inputs.isSpace,
parentSpaceId = inputs.parentSpaceId,
)

View file

@ -14,16 +14,35 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesBinding(SessionScope::class)
class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode(
isSpace: Boolean,
parentNode: Node,
buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback,
): Node {
val inputs = CreateRoomFlowNode.Inputs(isSpace)
return parentNode.createNode<CreateRoomFlowNode>(buildContext, listOf(inputs, callback))
class Builder(
private val parentNode: Node,
private val buildContext: BuildContext,
private val callback: CreateRoomEntryPoint.Callback,
) : CreateRoomEntryPoint.Builder {
private var isSpace = false
private var parentSpaceId: RoomId? = null
override fun setIsSpace(isSpace: Boolean): Builder {
this.isSpace = isSpace
return this
}
override fun setParentSpace(parentSpaceId: RoomId): Builder {
this.parentSpaceId = parentSpaceId
return this
}
override fun build(): Node {
val inputs = CreateRoomFlowNode.Inputs(isSpace = isSpace, parentSpaceId = parentSpaceId)
return parentNode.createNode<CreateRoomFlowNode>(buildContext, listOf(inputs, callback))
}
}
override fun builder(parentNode: Node, buildContext: BuildContext, callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.Builder {
return Builder(parentNode, buildContext, callback)
}
}

View file

@ -8,15 +8,16 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents
data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
data class JoinRuleChanged(val joinRuleItem: JoinRuleItem) : ConfigureRoomEvents
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data class SetParentSpace(val space: SpaceRoom?) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents
}

View file

@ -42,11 +42,12 @@ class ConfigureRoomNode(
@Parcelize
data class Inputs(
val isSpace: Boolean,
val parentSpaceId: RoomId?,
) : NodeInputs, Parcelable
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.isSpace)
private val presenter = presenterFactory.create(inputs.isSpace, inputs.parentSpaceId)
init {
lifecycle.subscribe(

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
@ -35,7 +36,9 @@ import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect
@ -45,15 +48,22 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import kotlin.jvm.optionals.getOrDefault
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration.Companion.seconds
@AssistedInject
class ConfigureRoomPresenter(
@Assisted private val isSpace: Boolean,
@Assisted private val initialParentSpaceId: RoomId?,
private val dataStore: CreateRoomConfigStore,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
@ -66,7 +76,7 @@ class ConfigureRoomPresenter(
) : Presenter<ConfigureRoomState> {
@AssistedFactory
interface Factory {
fun create(isSpace: Boolean): ConfigureRoomPresenter
fun create(isSpace: Boolean, parentSpaceId: RoomId?): ConfigureRoomPresenter
}
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@ -78,6 +88,7 @@ class ConfigureRoomPresenter(
@Composable
override fun present(): ConfigureRoomState {
val canAddRoomToSpace by featureFlagService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState()
val homeserverName = remember { matrixClient.userIdServerName() }
@ -105,6 +116,18 @@ class ConfigureRoomPresenter(
}
}
var spaces by remember { mutableStateOf<ImmutableList<SpaceRoom>>(persistentListOf()) }
LaunchedEffect(canAddRoomToSpace) {
spaces = if (canAddRoomToSpace) {
matrixClient.spaceService.editableSpaces().getOrElse { emptyList() }.toImmutableList()
} else {
persistentListOf()
}
val parentSpace = spaces.find { it.roomId == initialParentSpaceId }
parentSpace?.let { dataStore.setParentSpace(it) }
}
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
@ -115,7 +138,7 @@ class ConfigureRoomPresenter(
RoomAddressValidityEffect(
client = matrixClient,
roomAliasHelper = roomAliasHelper,
newRoomAddress = createRoomConfig.roomVisibility.roomAddress().getOrDefault(""),
newRoomAddress = createRoomConfig.visibilityState.roomAddress().getOrDefault(""),
knownRoomAddress = null,
) { newRoomAddressValidity ->
roomAddressValidity.value = newRoomAddressValidity
@ -124,6 +147,27 @@ class ConfigureRoomPresenter(
val localCoroutineScope = rememberCoroutineScope()
val createRoomAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
// Calculate available join rules based:
// 1. If we are creating a space.
// 2. If it has a parent space.
// 3. If knocking is enabled.
val parentSpace = createRoomConfig.parentSpace
val availableJoinRules = remember(createRoomConfig.parentSpace, isSpace, isKnockFeatureEnabled) {
when {
isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?")
parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.Private,
).toImmutableList()
else -> listOfNotNull(
JoinRuleItem.PublicVisibility.Restricted(parentSpace.roomId),
JoinRuleItem.PublicVisibility.AskToJoinRestricted(parentSpace.roomId).takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.Private,
).toImmutableList()
}
}
fun createRoom(config: CreateRoomConfig) {
createRoomAction.value = AsyncAction.Uninitialized
localCoroutineScope.createRoom(config, createRoomAction)
@ -133,8 +177,7 @@ class ConfigureRoomPresenter(
when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem)
is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.JoinRuleChanged -> dataStore.setJoinRule(event.joinRuleItem)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvents.HandleAvatarAction -> {
@ -149,19 +192,24 @@ class ConfigureRoomPresenter(
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
}
ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = AsyncAction.Uninitialized
is ConfigureRoomEvents.SetParentSpace -> {
dataStore.setParentSpace(event.space)
}
ConfigureRoomEvents.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized
}
}
}
return ConfigureRoomState(
isKnockFeatureEnabled = isKnockFeatureEnabled,
config = createRoomConfig,
avatarActions = avatarActions,
createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity.value,
availableJoinRules = availableJoinRules,
spaces = spaces,
eventSink = ::handleEvent,
)
}
@ -172,25 +220,27 @@ class ConfigureRoomPresenter(
) = launch {
suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) }
val params = if (config.roomVisibility is RoomVisibilityState.Public) {
val params = if (config.visibilityState is RoomVisibilityState.Public) {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = false,
isDirect = false,
visibility = RoomVisibility.Public,
joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(),
joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
// No need to specify the public join rule override, since the preset is already PUBLIC_CHAT
.takeIf { it != JoinRule.Public },
preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
roomAliasName = config.roomVisibility.roomAddress(),
roomAliasName = config.visibilityState.roomAddress(),
isSpace = isSpace,
)
} else {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
isEncrypted = config.visibilityState is RoomVisibilityState.Private,
isDirect = false,
visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
@ -200,7 +250,7 @@ class ConfigureRoomPresenter(
isSpace = isSpace,
)
}
matrixClient.createRoom(params)
val roomId = matrixClient.createRoom(params)
.onFailure { failure ->
Timber.e(failure, "Failed to create room")
}
@ -209,7 +259,22 @@ class ConfigureRoomPresenter(
analyticsService.capture(CreatedRoom(isDM = false))
}
.getOrThrow()
// Add the newly created room to the parent space too
if (config.parentSpace != null) {
Timber.d("Adding room $roomId to parent space ${config.parentSpace.roomId}")
// Wait until we receive the power level info for the room, as it's needed to check if it can be added to a space
// TODO create some SDK function that does this instead?
withTimeoutOrNull(30.seconds) {
matrixClient.getRoomInfoFlow(roomId).first { it.getOrNull()?.roomPowerLevels != null }
} ?: error("Did not receive created room power levels for room $roomId, needed for adding it to a space")
matrixClient.spaceService.addChildToSpace(spaceId = config.parentSpace.roomId, childId = roomId).getOrThrow()
}
roomId
}.runCatchingUpdatingState(createRoomAction)
.onFailure { Timber.e(it, "Could not create room or add it to parent space ${config.parentSpace?.roomId}") }
}
private suspend fun uploadAvatar(avatarUri: Uri): String {

View file

@ -10,21 +10,23 @@ package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState(
val isKnockFeatureEnabled: Boolean,
val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: AsyncAction<RoomId>,
val cameraPermissionState: PermissionsState,
val roomAddressValidity: RoomAddressValidity,
val homeserverName: String,
val availableJoinRules: ImmutableList<JoinRuleItem>,
val spaces: ImmutableList<SpaceRoom>,
val eventSink: (ConfigureRoomEvents) -> Unit
) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true &&
(config.roomVisibility is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid)
(config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid)
}

View file

@ -10,12 +10,15 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
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.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
@ -28,9 +31,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
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(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
),
@ -39,9 +42,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
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(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
),
@ -49,9 +52,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
roomAddressValidity = RoomAddressValidity.NotAvailable,
@ -60,9 +63,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
roomAddressValidity = RoomAddressValidity.InvalidSymbols,
@ -71,9 +74,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking,
joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
),
),
roomAddressValidity = RoomAddressValidity.Valid,
@ -83,13 +86,41 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
isSpace = true,
roomName = "Space 101",
topic = "Space topic for this space when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"),
roomAccess = RoomAccess.Anyone,
joinRuleItem = JoinRuleItem.PublicVisibility.Public,
),
),
roomAddressValidity = RoomAddressValidity.Valid,
),
aConfigureRoomState(
config = CreateRoomConfig(
isSpace = false,
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",
parentSpace = null,
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"),
joinRuleItem = JoinRuleItem.PublicVisibility.Restricted(aSpaceRoom().roomId),
),
),
spaces = listOf(aSpaceRoom()),
roomAddressValidity = RoomAddressValidity.Valid,
),
aConfigureRoomState(
config = CreateRoomConfig(
isSpace = false,
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",
parentSpace = aSpaceRoom(canonicalAlias = RoomAlias("#a-space-room:example.org")),
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Space-101"),
joinRuleItem = JoinRuleItem.PublicVisibility.Restricted(aSpaceRoom().roomId),
),
),
spaces = listOf(aSpaceRoom()),
roomAddressValidity = RoomAddressValidity.Valid,
),
)
}
@ -101,14 +132,29 @@ fun aConfigureRoomState(
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
homeserverName: String = "matrix.org",
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid,
availableVisibilityOptions: List<JoinRuleItem> = if (config.parentSpace != null) {
listOfNotNull(
JoinRuleItem.PublicVisibility.Restricted(config.parentSpace.roomId),
JoinRuleItem.PublicVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private,
)
} else {
listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private,
)
},
spaces: List<SpaceRoom> = emptyList(),
eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState(
config = config,
isKnockFeatureEnabled = isKnockFeatureEnabled,
avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity,
availableJoinRules = availableVisibilityOptions.toImmutableList(),
spaces = spaces.toImmutableList(),
eventSink = eventSink,
)

View file

@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
@ -46,7 +47,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -59,12 +59,14 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.AvatarPickerState
import io.element.android.libraries.matrix.ui.components.AvatarPickerView
import io.element.android.libraries.matrix.ui.room.address.RoomAddressField
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlin.jvm.optionals.getOrNull
@Composable
@ -119,27 +121,30 @@ fun ConfigureRoomView(
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
RoomVisibilityAndAccessOptions(
selected = when (state.config.roomVisibility) {
is RoomVisibilityState.Private -> RoomVisibilityItem.Private
is RoomVisibilityState.Public -> when (state.config.roomVisibility.roomAccess) {
RoomAccess.Knocking -> RoomVisibilityItem.AskToJoin
RoomAccess.Anyone -> RoomVisibilityItem.Public
}
},
isKnockingEnabled = state.isKnockFeatureEnabled,
if (!state.config.isSpace && state.spaces.isNotEmpty()) {
SelectParentSpaceOptions(
spaces = state.spaces,
selectedSpace = state.config.parentSpace,
onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
)
}
RoomJoinRuleOptions(
options = state.availableJoinRules,
selected = state.config.visibilityState.joinRuleItem,
parentSpace = state.config.parentSpace,
onOptionClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it))
state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it))
},
)
if (state.config.roomVisibility !is RoomVisibilityState.Private) {
if (state.config.visibilityState !is RoomVisibilityState.Private) {
Column {
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.roomVisibility.roomAddress().getOrNull().orEmpty(),
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
@ -264,7 +269,7 @@ private fun RoomTopic(
}
@Composable
private fun ConfigureRoomOptions(
internal fun ConfigureRoomOptions(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
@ -278,30 +283,29 @@ private fun ConfigureRoomOptions(
}
@Composable
private fun RoomVisibilityAndAccessOptions(
selected: RoomVisibilityItem,
isKnockingEnabled: Boolean,
onOptionClick: (RoomVisibilityItem) -> Unit,
private fun RoomJoinRuleOptions(
options: ImmutableList<JoinRuleItem>,
selected: JoinRuleItem,
onOptionClick: (JoinRuleItem) -> Unit,
parentSpace: SpaceRoom?,
modifier: Modifier = Modifier,
) {
ConfigureRoomOptions(
title = stringResource(R.string.screen_create_room_room_access_section_title),
modifier = modifier,
) {
RoomVisibilityItem.entries.forEach { item ->
if (item == RoomVisibilityItem.AskToJoin && !isKnockingEnabled) {
return@forEach
}
options.forEach { item ->
val isSelected = item == selected
ListItem(
leadingContent = ListItemContent.Custom {
RoundedIconAtom(
size = RoundedIconAtomSize.Big,
resourceId = when (item) {
RoomVisibilityItem.Public -> CompoundDrawables.ic_compound_public
RoomVisibilityItem.AskToJoin -> CompoundDrawables.ic_compound_user_add
RoomVisibilityItem.Private -> CompoundDrawables.ic_compound_lock
imageVector = when (item) {
JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public()
is JoinRuleItem.PublicVisibility.Restricted -> CompoundIcons.Space()
JoinRuleItem.PublicVisibility.AskToJoin,
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.Private -> CompoundIcons.Lock()
},
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
backgroundTint = Color.Transparent,
@ -309,18 +313,29 @@ private fun RoomVisibilityAndAccessOptions(
},
headlineContent = {
val title = when (item) {
RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_title)
RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_title)
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
is JoinRuleItem.PublicVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
R.string.screen_create_room_room_access_section_knocking_restricted_option_title
)
JoinRuleItem.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_title)
}
Text(text = title)
},
supportingContent = {
// TODO handle description of items in a certain space/org
val description = when (item) {
RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_short_description)
RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_description)
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_description)
is JoinRuleItem.PublicVisibility.Restricted -> stringResource(
R.string.screen_create_room_room_access_section_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
R.string.screen_create_room_room_access_section_knocking_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
JoinRuleItem.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_description)
}
Text(text = description)
},

View file

@ -8,6 +8,7 @@
package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -18,5 +19,6 @@ data class CreateRoomConfig(
val topic: String? = null,
val avatarUri: String? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(),
val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private,
val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(),
val parentSpace: SpaceRoom? = null,
)

View file

@ -12,6 +12,7 @@ import android.net.Uri
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
@ -33,23 +34,20 @@ class CreateRoomConfigStore(
fun setRoomName(roomName: String) {
createRoomConfigFlow.getAndUpdate { config ->
val newVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
val roomAddress = config.roomVisibility.roomAddress
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName)
config.roomVisibility.copy(
roomAddress = RoomAddress.AutoFilled(roomAliasName),
)
} else {
config.roomVisibility
}
val roomAccessWithNewAddress = if (config.visibilityState is RoomVisibilityState.Public) {
val roomAddress = config.visibilityState.roomAddress
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName)
config.visibilityState.copy(roomAddress = RoomAddress.AutoFilled(roomAliasName))
} else {
config.visibilityState
}
else -> config.roomVisibility
} else {
config.visibilityState
}
config.copy(
roomName = roomName.takeIf { it.isNotEmpty() },
roomVisibility = newVisibility,
visibilityState = roomAccessWithNewAddress,
)
}
}
@ -67,16 +65,19 @@ class CreateRoomConfigStore(
}
}
fun setRoomVisibility(visibility: RoomVisibilityItem) {
/**
* Sets both the room visibility and its access based on the provided join rule.
*/
fun setJoinRule(joinRule: JoinRuleItem) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (visibility) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private
RoomVisibilityItem.Public, RoomVisibilityItem.AskToJoin -> {
visibilityState = when (joinRule) {
JoinRuleItem.Private -> RoomVisibilityState.Private()
is JoinRuleItem.PublicVisibility -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasName),
roomAccess = if (visibility == RoomVisibilityItem.AskToJoin) RoomAccess.Knocking else RoomAccess.Anyone,
joinRuleItem = joinRule,
)
}
}
@ -87,28 +88,12 @@ class CreateRoomConfigStore(
fun setRoomAddress(address: String) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
visibilityState = when (config.visibilityState) {
is RoomVisibilityState.Public -> {
val sanitizedAddress = address.lowercase()
config.roomVisibility.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
config.visibilityState.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
}
else -> config.roomVisibility
}
)
}
}
fun setRoomAccess(access: RoomAccessItem) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
when (access) {
RoomAccessItem.Anyone -> config.roomVisibility.copy(roomAccess = RoomAccess.Anyone)
RoomAccessItem.AskToJoin -> config.roomVisibility.copy(roomAccess = RoomAccess.Knocking)
}
}
else -> config.roomVisibility
else -> config.visibilityState
}
)
}
@ -120,6 +105,15 @@ class CreateRoomConfigStore(
}
}
fun setParentSpace(parentSpace: SpaceRoom?) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
parentSpace = parentSpace,
visibilityState = RoomVisibilityState.Private(),
)
}
}
fun clearCachedData() {
cachedAvatarUri = null
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.join.AllowRule
import io.element.android.libraries.matrix.api.room.join.JoinRule
import kotlinx.collections.immutable.persistentListOf
/**
* Join rule items to display in UI.
*/
@Immutable
sealed interface JoinRuleItem {
data object Private : JoinRuleItem
/**
* Those join rule items that represent public visibility of the room/space.
*/
@Immutable
sealed interface PublicVisibility : JoinRuleItem {
data object Public : PublicVisibility
data object AskToJoin : PublicVisibility
data class Restricted(val parentSpaceId: RoomId) : PublicVisibility
data class AskToJoinRestricted(val parentSpaceId: RoomId) : PublicVisibility
}
/**
* Transforms a [JoinRuleItem] option into a [JoinRule].
*/
fun toJoinRule(): JoinRule = when (this) {
Private -> JoinRule.Private
PublicVisibility.Public -> JoinRule.Public
PublicVisibility.AskToJoin -> JoinRule.Knock
is PublicVisibility.Restricted -> JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
is PublicVisibility.AskToJoinRestricted -> JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
}
}

View file

@ -1,23 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.room.join.JoinRule
enum class RoomAccess {
Anyone,
Knocking
}
fun RoomAccess.toJoinRule(): JoinRule? {
return when (this) {
RoomAccess.Anyone -> null
RoomAccess.Knocking -> JoinRule.Knock
}
}

View file

@ -1,14 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
enum class RoomAccessItem {
Anyone,
AskToJoin,
}

View file

@ -1,15 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
enum class RoomVisibilityItem {
Public,
AskToJoin,
Private
}

View file

@ -11,11 +11,12 @@ package io.element.android.features.createroom.impl.configureroom
import java.util.Optional
sealed interface RoomVisibilityState {
data object Private : RoomVisibilityState
val joinRuleItem: JoinRuleItem
data class Private(override val joinRuleItem: JoinRuleItem.Private = JoinRuleItem.Private) : RoomVisibilityState
data class Public(
val roomAddress: RoomAddress,
val roomAccess: RoomAccess,
override val joinRuleItem: JoinRuleItem.PublicVisibility,
) : RoomVisibilityState
fun roomAddress(): Optional<String> {

View file

@ -0,0 +1,209 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar
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.AvatarType
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SelectParentSpaceOptions(
spaces: ImmutableList<SpaceRoom>,
selectedSpace: SpaceRoom?,
onSelectSpace: (SpaceRoom?) -> Unit,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
var displaySelectSpaceBottomSheet by remember { mutableStateOf(false) }
ConfigureRoomOptions(
title = stringResource(CommonStrings.common_space),
modifier = modifier
) {
ListItem(
headlineContent = {
Text(
text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
maxLines = 1
)
},
supportingContent = {
Text(
text = if (selectedSpace != null) {
selectedSpace.canonicalAlias?.value.orEmpty()
} else {
stringResource(R.string.screen_create_room_space_selection_no_space_description)
},
maxLines = 1
)
},
leadingContent = if (selectedSpace == null) {
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Home()))
} else {
ListItemContent.Custom({
val avatarData = AvatarData(
id = selectedSpace.roomId.value,
name = selectedSpace.displayName,
url = selectedSpace.avatarUrl,
size = AvatarSize.SelectParentSpace,
)
Avatar(avatarData = avatarData, avatarType = AvatarType.Space())
})
},
onClick = { displaySelectSpaceBottomSheet = true }
)
if (displaySelectSpaceBottomSheet) {
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { true },
)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
sheetState.hide(coroutineScope) {
displaySelectSpaceBottomSheet = false
}
}
) {
SelectParentSpaceBottomSheet(
spaces = spaces,
selectedSpace = selectedSpace,
) {
sheetState.hide(coroutineScope) {
displaySelectSpaceBottomSheet = false
}
onSelectSpace(it)
}
}
}
}
}
@Composable
private fun ColumnScope.SelectParentSpaceBottomSheet(
spaces: ImmutableList<SpaceRoom>,
selectedSpace: SpaceRoom?,
onSelectSpace: (SpaceRoom?) -> Unit,
) {
ListSectionHeader(
title = stringResource(R.string.screen_create_room_space_selection_sheet_title),
hasDivider = false
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
ListItem(
headlineContent = {
Text(
stringResource(R.string.screen_create_room_space_selection_no_space_title),
maxLines = 1
)
},
supportingContent = {
Text(
stringResource(R.string.screen_create_room_space_selection_no_space_description),
maxLines = 1
)
},
leadingContent = ListItemContent.Icon(
IconSource.Vector(CompoundIcons.Home())
),
trailingContent = ListItemContent.RadioButton(
selected = selectedSpace == null
),
onClick = { onSelectSpace(null) },
)
}
for (space in spaces) {
item {
ListItem(
headlineContent = {
Text(
space.displayName,
maxLines = 1
)
},
supportingContent = {
Text(
space.canonicalAlias?.value.orEmpty(),
maxLines = 1
)
},
leadingContent = ListItemContent.Custom({
val avatarData =
AvatarData(
id = space.roomId.value,
name = space.displayName,
url = space.avatarUrl,
size = AvatarSize.SelectParentSpace,
)
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space()
)
}),
trailingContent = ListItemContent.RadioButton(
selected = selectedSpace == space
),
onClick = { onSelectSpace(space) },
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun SelectParentSpaceBottomSheetPreview() =
ElementPreview {
Column {
SelectParentSpaceBottomSheet(
spaces = persistentListOf(
aSpaceRoom(
canonicalAlias = RoomAlias(
"#a-room-alias:example.org"
)
)
),
selectedSpace = null,
) {}
}
}

View file

@ -3,15 +3,30 @@
<string name="screen_create_room_action_create_room">"Nyt rum"</string>
<string name="screen_create_room_add_people_title">"Invitér andre"</string>
<string name="screen_create_room_error_creating_room">"Der opstod en fejl ved oprettelsen af rummet"</string>
<string name="screen_create_room_private_option_description">"Kun inviterede personer kan få adgang til dette rum. Alle meddelelser er ende-til-ende krypteret."</string>
<string name="screen_create_room_error_creating_space">"Gruppen kunne ikke oprettes på grund af en ukendt fejl. Prøv igen senere."</string>
<string name="screen_create_room_name_placeholder">"Tilføj navn…"</string>
<string name="screen_create_room_new_room_title">"Nyt rum"</string>
<string name="screen_create_room_new_space_title">"Ny gruppe"</string>
<string name="screen_create_room_private_option_description">"Kun inviterede personer kan deltage."</string>
<string name="screen_create_room_private_option_title">"Privat"</string>
<string name="screen_create_room_public_option_description">"Alle kan finde dette rum.
Du kan ændre dette når som helst i rummets indstillinger."</string>
<string name="screen_create_room_public_option_short_description">"Alle kan deltage."</string>
<string name="screen_create_room_public_option_title">"Offentlig"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Alle kan bede om at deltage i rummet, men en administrator eller en moderator skal acceptere anmodningen"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Spørg om at deltage"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Tillad at man kan anmode om deltagelse"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Enhver i %1$s kan deltage, men alle andre skal anmode om adgang."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Anmod om at deltage"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Kun inviterede brugere kan deltage."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privat"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle kan deltage i dette rum"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Enhver"</string>
<string name="screen_create_room_room_address_section_footer">"Hvis dette rum skal være synligt i det offentlige register, skal du bruge en rum-adresse."</string>
<string name="screen_create_room_room_address_section_title">"Rummets adresse"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Offentlig"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Alle i %1$s kan deltage."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
<string name="screen_create_room_room_access_section_title">"Hvem har adgang"</string>
<string name="screen_create_room_room_address_section_footer">"Hvis dette rum skal være synligt i det offentlige register, skal du bruge en adresse."</string>
<string name="screen_create_room_room_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">"Rummets synlighed"</string>
<string name="screen_create_room_topic_label">"Emne (valgfrit)"</string>
<string name="screen_create_room_topic_placeholder">"Tilføj beskrivelse…"</string>
</resources>

View file

@ -15,14 +15,20 @@ Du kannst dies jederzeit in den Einstellungen des Chats ändern."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Chatroom"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Anfrage zum Beitritt zulassen"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Jeder in %1$s kann beitreten, aber alle anderen müssen den Beitritt anfragen."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Beitritt anfragen"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Nur eingeladene Personen können beitreten."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privat"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Jeder darf diesem Chat beitreten."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Jeder"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Jeder in %1$s kann beitreten."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
<string name="screen_create_room_room_access_section_title">"Wer hat Zugang"</string>
<string name="screen_create_room_room_address_section_footer">"Du benötigst eine Adresse, um diesen Chat im öffentlichen Verzeichnis sichtbar zu machen."</string>
<string name="screen_create_room_room_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chats"</string>
<string name="screen_create_room_space_selection_no_space_description">"(kein Space)"</string>
<string name="screen_create_room_space_selection_sheet_title">"Space hinzufügen"</string>
<string name="screen_create_room_topic_label">"Thema (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Beschreibung hinzufügen…"</string>
</resources>

View file

@ -15,14 +15,21 @@ 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_room_access_section_knocking_option_description">"Tout le monde peut demander à joindre, mais un administrateur ou un modérateur devra accepter la demande"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Autoriser la demande à joindre"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Tout membre de %1$s peut joindre, mais les autres doivent demander un accès."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Demander à joindre"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Seules les personnes invitées peuvent joindre."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privé"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Tout le monde peut joindre"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Tout le monde"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Toute membre de %1$s peut joindre le salon."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
<string name="screen_create_room_room_access_section_title">"Qui a accès"</string>
<string name="screen_create_room_room_address_section_footer">"Vous aurez besoin dune adresse pour quil soit visible dans le répertoire public."</string>
<string name="screen_create_room_room_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilité du salon"</string>
<string name="screen_create_room_space_selection_no_space_description">"(pas despace)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Accueil"</string>
<string name="screen_create_room_space_selection_sheet_title">"Ajouter à lespace"</string>
<string name="screen_create_room_topic_label">"Sujet (facultatif)"</string>
<string name="screen_create_room_topic_placeholder">"Ajouter une description…"</string>
</resources>

View file

@ -3,16 +3,33 @@
<string name="screen_create_room_action_create_room">"Új szoba"</string>
<string name="screen_create_room_add_people_title">"Ismerősök meghívása"</string>
<string name="screen_create_room_error_creating_room">"Hiba történt a szoba létrehozásakor"</string>
<string name="screen_create_room_private_option_description">"Csak a meghívottak léphetnek be ebbe a szobába. Az összes üzenet végpontok közti titkosítással van védve."</string>
<string name="screen_create_room_error_creating_space">"A teret ismeretlen hiba miatt nem sikerült létrehozni. Próbálja újra később."</string>
<string name="screen_create_room_name_placeholder">"Név hozzáadása…"</string>
<string name="screen_create_room_new_room_title">"Új szoba"</string>
<string name="screen_create_room_new_space_title">"Új tér"</string>
<string name="screen_create_room_private_option_description">"Csak a meghívottak léphetnek be."</string>
<string name="screen_create_room_private_option_title">"Privát"</string>
<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>
<string name="screen_create_room_public_option_short_description">"Bárki csatlakozhat."</string>
<string name="screen_create_room_public_option_title">"Nyilvános szoba"</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_description">"Bárki kérheti, hogy csatlakozhasson 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_access_section_public_option_description">"Bárki csatlakozhat ehhez a szobához"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Bárki"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Bárki csatlakozhat innen: %1$s, de mindenki másnak hozzáférést kell kérnie."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Csatlakozás kérése"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Csak a meghívottak léphetnek be."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privát"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Bárki csatlakozhat."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Nyilvános"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Bárki csatlakozhat innen: %1$s."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Szokásos"</string>
<string name="screen_create_room_room_access_section_title">"Hozzáférésre jogosultak"</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_address_section_title">"Cím"</string>
<string name="screen_create_room_room_visibility_section_title">"Szoba láthatósága"</string>
<string name="screen_create_room_space_selection_no_space_description">"(nincs tér)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Kezdőlap"</string>
<string name="screen_create_room_space_selection_sheet_title">"Hozzáadás a térhez"</string>
<string name="screen_create_room_topic_label">"Téma (nem kötelező)"</string>
<string name="screen_create_room_topic_placeholder">"Leírás hozzáadása…"</string>
</resources>

View file

@ -3,16 +3,17 @@
<string name="screen_create_room_action_create_room">"Nytt rom"</string>
<string name="screen_create_room_add_people_title">"Inviter folk"</string>
<string name="screen_create_room_error_creating_room">"Det oppsto en feil under opprettelsen av rommet"</string>
<string name="screen_create_room_private_option_description">"Bare inviterte personer har tilgang til dette rommet. Alle meldinger er ende-til-ende-kryptert."</string>
<string name="screen_create_room_private_option_description">"Bare inviterte personer kan bli med."</string>
<string name="screen_create_room_private_option_title">"Privat"</string>
<string name="screen_create_room_public_option_description">"Alle kan finne dette rommet.
Du kan endre dette når som helst i rominnstillingene."</string>
<string name="screen_create_room_public_option_title">"Offentlig rom"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Alle kan be om å få bli med i rommet, men en administrator eller moderator må godta forespørselen"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Alle kan be om å få bli med, men en administrator eller moderator må godta forespørselen."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Be om å bli med"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle kan bli med i dette rommet"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle kan bli med."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Alle"</string>
<string name="screen_create_room_room_address_section_footer">"For at dette rommet skal være synlig i den offentlige romkatalogen, trenger du en romadresse."</string>
<string name="screen_create_room_room_address_section_title">"Romadresse"</string>
<string name="screen_create_room_room_address_section_footer">"Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."</string>
<string name="screen_create_room_room_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">"Romsynlighet"</string>
<string name="screen_create_room_topic_label">"Emne (valgfritt)"</string>
</resources>

View file

@ -4,6 +4,8 @@
<string name="screen_create_room_add_people_title">"Convidar pessoas"</string>
<string name="screen_create_room_error_creating_room">"Ocorreu um erro ao criar a sala"</string>
<string name="screen_create_room_error_creating_space">"O espaço não pôde ser criado por conta de um erro desconhecido. Tente novamente mais tarde."</string>
<string name="screen_create_room_name_placeholder">"Adicione o nome…"</string>
<string name="screen_create_room_new_room_title">"Nova sala"</string>
<string name="screen_create_room_new_space_title">"Novo espaço"</string>
<string name="screen_create_room_private_option_description">"Apenas pessoas convidadas podem entrar."</string>
<string name="screen_create_room_private_option_title">"Privada"</string>
@ -12,12 +14,22 @@ Você pode mudar isso a qualquer momento nas configurações da sala."</string>
<string name="screen_create_room_public_option_short_description">"Qualquer um pode entrar."</string>
<string name="screen_create_room_public_option_title">"Publica"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Qualquer um pode pedir para entrar, mas um administrador ou moderador deve aceitar a solicitação"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Pedir para entrar"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Permitir pedir para entrar"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Qualquer um em %1$s pode entrar, mas todos os outros devem pedir acesso."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Pedir para entrar"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Apenas pessoas convidadas podem entrar."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privada"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Qualquer um pode entrar."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Qualquer pessoa"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Qualquer um em %1$s pode participar."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Normal"</string>
<string name="screen_create_room_room_access_section_title">"Quem tem acesso"</string>
<string name="screen_create_room_room_address_section_footer">"Para que esta sala fique visível no diretório público de salas, você precisará 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_address_section_footer">"Você precisará de um endereço para torná-la visível no diretório público."</string>
<string name="screen_create_room_room_address_section_title">"Endereço"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilidade da sala"</string>
<string name="screen_create_room_space_selection_no_space_description">"(sem espaço)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Início"</string>
<string name="screen_create_room_space_selection_sheet_title">"Adicionar ao espaço"</string>
<string name="screen_create_room_topic_label">"Tópico (opcional)"</string>
<string name="screen_create_room_topic_placeholder">"Adicione a descrição…"</string>
</resources>

View file

@ -3,16 +3,17 @@
<string name="screen_create_room_action_create_room">"Nova sala"</string>
<string name="screen_create_room_add_people_title">"Convidar pessoas"</string>
<string name="screen_create_room_error_creating_room">"Ocorreu um erro ao criar a sala"</string>
<string name="screen_create_room_private_option_description">"Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são cifradas ponta-a-ponta."</string>
<string name="screen_create_room_private_option_description">"Apenas as pessoas convidadas podem entrar."</string>
<string name="screen_create_room_private_option_title">"Privada"</string>
<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>
<string name="screen_create_room_public_option_title">"Sala pública"</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_description">"Qualquer pessoa pode pedir para entrar, 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_access_section_public_option_description">"Qualquer pessoa pode entrar nesta sala"</string>
<string name="screen_create_room_room_access_section_public_option_title">"Qualquer pessoa"</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_access_section_public_option_description">"Qualquer pessoa pode entrar."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Pública"</string>
<string name="screen_create_room_room_address_section_footer">"Para que esta sala seja visível no diretório público, precisa de ter um endereço."</string>
<string name="screen_create_room_room_address_section_title">"Endereço"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilidade da sala"</string>
<string name="screen_create_room_topic_label">"Descrição (opcional)"</string>
</resources>

View file

@ -3,16 +3,26 @@
<string name="screen_create_room_action_create_room">"Создать новую комнату"</string>
<string name="screen_create_room_add_people_title">"Пригласить в комнату"</string>
<string name="screen_create_room_error_creating_room">"Произошла ошибка при создании комнаты"</string>
<string name="screen_create_room_private_option_description">"Доступ в эту комнату имеют только приглашенные пользователи. Все сообщения защищены сквозным шифрованием."</string>
<string name="screen_create_room_error_creating_space">"Не удалось создать это пространство из-за неизвестной ошибки. Попробуйте позже."</string>
<string name="screen_create_room_name_placeholder">"Добавьте имя…"</string>
<string name="screen_create_room_new_room_title">"Новая комната"</string>
<string name="screen_create_room_new_space_title">"Новое пространство"</string>
<string name="screen_create_room_private_option_description">"Присоединиться могут только приглашенные."</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_short_description">"Присоединиться может любой."</string>
<string name="screen_create_room_public_option_title">"Общедоступная комната"</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_access_section_public_option_description">"Любой желающий может присоединиться к этой комнате"</string>
<string name="screen_create_room_room_access_section_public_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_access_section_knocking_option_title">"Разрешить запрос на присоединение"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Присоединиться могут только приглашенные."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Частный"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Присоединиться может любой желающий."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Публичный"</string>
<string name="screen_create_room_room_access_section_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_visibility_section_title">"Видимость комнаты"</string>
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
<string name="screen_create_room_topic_placeholder">"Добавить описание…"</string>
</resources>

View file

@ -3,16 +3,33 @@
<string name="screen_create_room_action_create_room">"Nová miestnosť"</string>
<string name="screen_create_room_add_people_title">"Pozvať ľudí"</string>
<string name="screen_create_room_error_creating_room">"Pri vytváraní miestnosti došlo k chybe"</string>
<string name="screen_create_room_private_option_description">"Do tejto miestnosti majú prístup iba pozvaní ľudia. Všetky správy sú end-to-end šifrované."</string>
<string name="screen_create_room_error_creating_space">"Priestor sa nepodarilo vytvoriť z dôvodu neznámej chyby. Skúste to znova neskôr."</string>
<string name="screen_create_room_name_placeholder">"Pridať názov…"</string>
<string name="screen_create_room_new_room_title">"Nová miestnosť"</string>
<string name="screen_create_room_new_space_title">"Nový priestor"</string>
<string name="screen_create_room_private_option_description">"Pripojiť sa môžu iba pozvaní ľudia."</string>
<string name="screen_create_room_private_option_title">"Súkromná"</string>
<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>
<string name="screen_create_room_public_option_short_description">"Pripojiť sa môže ktokoľvek."</string>
<string name="screen_create_room_public_option_title">"Verejná miestnosť"</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_access_section_public_option_description">"Do tejto miestnosti sa môže pripojiť ktokoľvek"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"O pripojenie sa môže požiadať ktokoľvek, ale žiadosť musí schváliť správca alebo moderátor."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Povoliť požiadať o vstup"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Ktokoľvek v %1$s sa môžu pripojiť, ale všetci ostatní musia požiadať o prístup."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Požiadať o pripojenie"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Pripojiť sa môžu iba pozvaní ľudia."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Súkromná"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Pripojiť sa môže ktokoľvek."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Ktokoľvek"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Ktokoľvek v %1$s sa môže pripojiť."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Štandardná"</string>
<string name="screen_create_room_room_access_section_title">"Kto má prístup"</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_address_section_title">"Adresa"</string>
<string name="screen_create_room_room_visibility_section_title">"Viditeľnosť miestnosti"</string>
<string name="screen_create_room_space_selection_no_space_description">"(žiadny priestor)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Domov"</string>
<string name="screen_create_room_space_selection_sheet_title">"Pridať do priestoru"</string>
<string name="screen_create_room_topic_label">"Téma (voliteľné)"</string>
<string name="screen_create_room_topic_placeholder">"Pridať popis…"</string>
</resources>

View file

@ -7,6 +7,8 @@
<string name="screen_create_room_name_placeholder">"Add name…"</string>
<string name="screen_create_room_new_room_title">"New room"</string>
<string name="screen_create_room_new_space_title">"New space"</string>
<string name="screen_create_room_parent_space_home_description">"(no space)"</string>
<string name="screen_create_room_parent_space_home_title">"Home"</string>
<string name="screen_create_room_private_option_description">"Only people invited can join."</string>
<string name="screen_create_room_private_option_title">"Private"</string>
<string name="screen_create_room_public_option_description">"Anyone can find this room.
@ -15,14 +17,21 @@ You can change this anytime in room settings."</string>
<string name="screen_create_room_public_option_title">"Public"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Anyone can ask to join but an administrator or a moderator must accept the request."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Allow ask to join"</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_description">"Anyone in %1$s can join but everyone else must request access."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Ask to join"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Only people invited can join."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Private"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Anyone can join."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Public"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Anyone in %1$s can join."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
<string name="screen_create_room_room_access_section_title">"Who has access"</string>
<string name="screen_create_room_room_address_section_footer">"Youll need an address in order to make it visible in the public directory."</string>
<string name="screen_create_room_room_address_section_title">"Address"</string>
<string name="screen_create_room_room_visibility_section_title">"Room visibility"</string>
<string name="screen_create_room_space_selection_no_space_description">"(no space)"</string>
<string name="screen_create_room_space_selection_no_space_title">"Home"</string>
<string name="screen_create_room_space_selection_sheet_title">"Add to space"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Add description…"</string>
</resources>

View file

@ -1,12 +1,11 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.startchat.impl.configureroom
package io.element.android.features.createroom.impl
import android.net.Uri
import androidx.core.net.toUri
@ -18,24 +17,28 @@ import io.element.android.features.createroom.impl.configureroom.ConfigureRoomPr
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomState
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfigStore
import io.element.android.features.createroom.impl.configureroom.RoomAccess
import io.element.android.features.createroom.impl.configureroom.JoinRuleItem
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.alias.FakeRoomAliasHelper
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.mediapickers.api.PickerProvider
@ -47,12 +50,17 @@ 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.FakePermissionsPresenterFactory
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.mockk.mockk
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -81,7 +89,7 @@ class ConfigureRoomPresenterTest {
assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private)
assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private())
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org")
}
@ -174,12 +182,12 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy
newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
newState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
newState = awaitItem()
expectedConfig = expectedConfig.copy(
roomVisibility = RoomVisibilityState.Public(
visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")),
roomAccess = RoomAccess.Anyone,
joinRuleItem = JoinRuleItem.PublicVisibility.Public,
)
)
assertThat(newState.config).isEqualTo(expectedConfig)
@ -206,6 +214,109 @@ class ConfigureRoomPresenterTest {
}
}
@Test
fun `present - when creating a room in a space if the room doesn't receive the power levels value it can't be added to the space`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
val getRoomInfoFlowLambda = lambdaRecorder<RoomId, Flow<Optional<RoomInfo>>> { roomInfoFlow }
val matrixClient = createMatrixClient(spaceService = spaceService).apply {
this.getRoomInfoFlowLambda = getRoomInfoFlowLambda
}
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
matrixClient.givenCreateRoomResult(createRoomResult)
val parentSpace = aSpaceRoom()
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
// getRoomInfoFlow is called, but it contains no updates
getRoomInfoFlowLambda.assertions().isCalledOnce()
// So adding the child room to the parent space is never done
addChildToSpaceResult.assertions().isNeverCalled()
// And the operation fails
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
@Test
fun `present - creating a room and adding it into a parent space works when all the data is available`() = runTest {
val addChildToSpaceResult = lambdaRecorder<RoomId, RoomId, Result<Unit>> { _, _ -> Result.success(Unit) }
val spaceService = FakeSpaceService(
addChildToSpaceResult = addChildToSpaceResult,
)
val roomInfoFlow = MutableStateFlow<Optional<RoomInfo>>(Optional.empty())
val getRoomInfoFlowLambda = lambdaRecorder<RoomId, Flow<Optional<RoomInfo>>> { roomInfoFlow }
val matrixClient = createMatrixClient(spaceService = spaceService).apply {
this.getRoomInfoFlowLambda = getRoomInfoFlowLambda
}
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
matrixClient.givenCreateRoomResult(createRoomResult)
val parentSpace = aSpaceRoom()
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin)
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
// We immediately receive the room power levels info needed for adding the child to a space
val powerLevels = RoomPowerLevels(
RoomPowerLevelsValues(
ban = 0,
invite = 0,
kick = 0,
eventsDefault = 0,
stateDefault = 0,
redactEvents = 0,
roomName = 0,
roomAvatar = 0,
roomTopic = 0,
spaceChild = 0
),
users = persistentMapOf(),
)
roomInfoFlow.value = Optional.of(aRoomInfo(roomPowerLevels = powerLevels))
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem()
// The room info flow was read
getRoomInfoFlowLambda.assertions().isCalledOnce()
// Since it contained the power levels, the operation continued
addChildToSpaceResult.assertions().isCalledOnce()
// And the child room was created and then added to the parent space
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(stateAfterCreateRoom.createRoomAction.dataOrNull()).isEqualTo(createRoomResult.getOrNull())
}
}
@Test
fun `present - record analytics when creating room`() = runTest {
val matrixClient = createMatrixClient()
@ -311,7 +422,7 @@ class ConfigureRoomPresenterTest {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
skipItems(1)
@ -331,7 +442,7 @@ class ConfigureRoomPresenterTest {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1)
@ -351,7 +462,7 @@ class ConfigureRoomPresenterTest {
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1)
@ -362,12 +473,51 @@ class ConfigureRoomPresenterTest {
}
}
@Test
fun `present - when a space is selected, the selected join rule is reset to private`() = runTest {
val presenter = createConfigureRoomPresenter()
presenter.test {
val initialState = initialState()
// First change the join rule to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then check changing the parent space resets it to private
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
// If we change the join rule back to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then remove the parent space, it'll be private again
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(null))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
}
}
@Test
fun `present - setting a parent space for a space currently throws an error`() = runTest {
val presenter = createConfigureRoomPresenter(isSpace = true)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
assertThat(awaitError())
}
}
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
}
private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient(
private fun createMatrixClient(
isAliasAvailable: Boolean = true,
spaceService: FakeSpaceService = FakeSpaceService(),
) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = {
val resolvedRoomAlias = if (isAliasAvailable) {
@ -376,11 +526,13 @@ class ConfigureRoomPresenterTest {
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
}
Result.success(resolvedRoomAlias)
}
},
spaceService = spaceService,
)
private fun createConfigureRoomPresenter(
isSpace: Boolean = false,
initialParenSpaceId: RoomId? = null,
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper),
matrixClient: MatrixClient = createMatrixClient(),
@ -392,6 +544,7 @@ class ConfigureRoomPresenterTest {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = ConfigureRoomPresenter(
isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId,
dataStore = dataStore,
matrixClient = matrixClient,
mediaPickerProvider = pickerProvider,

View file

@ -14,6 +14,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
@ -36,15 +37,16 @@ class DefaultCreateRoomEntryPointTest {
plugins = plugins,
)
}
val buildContext = BuildContext.root(null)
val callback = object : CreateRoomEntryPoint.Callback {
override fun onRoomCreated(roomId: RoomId) = lambdaError()
}
val result = entryPoint.createNode(
isSpace = false,
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
)
val result = entryPoint
.builder(parentNode, buildContext, callback)
.setIsSpace(true)
.setParentSpace(A_ROOM_ID)
.build()
assertThat(result.plugins).contains(callback)
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.createroom.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.configureroom.JoinRuleItem
import io.element.android.libraries.matrix.api.room.join.AllowRule
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.test.A_ROOM_ID
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class JoinRuleItemTest {
@Test
fun `toJoinRule works as expected`() {
assertThat(JoinRuleItem.Private.toJoinRule()).isEqualTo(JoinRule.Private)
assertThat(JoinRuleItem.PublicVisibility.Public.toJoinRule()).isEqualTo(JoinRule.Public)
assertThat(JoinRuleItem.PublicVisibility.AskToJoin.toJoinRule()).isEqualTo(JoinRule.Knock)
assertThat(JoinRuleItem.PublicVisibility.Restricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
assertThat(JoinRuleItem.PublicVisibility.AskToJoinRestricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
}
}

View file

@ -16,5 +16,6 @@ android {
dependencies {
implementation(projects.features.createroom.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
}

View file

@ -10,13 +10,19 @@ package io.element.android.features.createroom.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode(
isSpace: Boolean,
class Builder : CreateRoomEntryPoint.Builder {
override fun setIsSpace(isSpace: Boolean): Builder = this
override fun setParentSpace(parentSpaceId: RoomId): Builder = this
override fun build(): Node = lambdaError()
}
override fun builder(
parentNode: Node,
buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback,
): Node = lambdaError()
): Builder = lambdaError()
}

View file

@ -94,9 +94,9 @@ class HomePresenter(
}
}
LaunchedEffect(homeSpacesState.spaceRooms.isEmpty()) {
// If the last space is left, ensure that the Chat view is rendered.
if (homeSpacesState.spaceRooms.isEmpty()) {
LaunchedEffect(homeSpacesState.canCreateSpaces, homeSpacesState.spaceRooms.isEmpty()) {
// If the flag to create spaces is disabled and the last space is left, ensure that the Chat view is rendered.
if (!homeSpacesState.canCreateSpaces && homeSpacesState.spaceRooms.isEmpty()) {
currentHomeNavigationBarItemOrdinal = HomeNavigationBarItem.Chats.ordinal
}
}

View file

@ -33,5 +33,5 @@ data class HomeState(
) {
val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters
val showNavigationBar = homeSpacesState.spaceRooms.isNotEmpty()
val showNavigationBar = homeSpacesState.canCreateSpaces || homeSpacesState.spaceRooms.isNotEmpty()
}

View file

@ -268,7 +268,10 @@ private fun HomeScaffold(
lazyListState = spacesLazyListState,
onSpaceClick = { spaceId ->
onRoomClick(spaceId)
}
},
onCreateSpaceClick = onCreateSpaceClick,
// TODO use actual callbacks for this
onExploreClick = {},
)
}
}

View file

@ -8,7 +8,9 @@
package io.element.android.features.home.impl.search
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.home.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -18,6 +20,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
@ -25,16 +28,23 @@ import kotlinx.coroutines.flow.map
private const val PAGE_SIZE = 30
@Inject
@AssistedInject
class RoomListSearchDataSource(
@Assisted coroutineScope: CoroutineScope,
roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
private val roomSummaryFactory: RoomListRoomSummaryFactory,
) {
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource
}
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.None,
source = RoomList.Source.All,
coroutineScope = coroutineScope
)
val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.filteredSummaries

View file

@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
@ -23,7 +24,7 @@ import kotlinx.collections.immutable.persistentListOf
@Inject
class RoomListSearchPresenter(
private val dataSource: RoomListSearchDataSource,
private val dataSourceFactory: RoomListSearchDataSource.Factory,
) : Presenter<RoomListSearchState> {
@Composable
override fun present(): RoomListSearchState {
@ -33,6 +34,9 @@ class RoomListSearchPresenter(
}
val searchQuery = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}

View file

@ -53,6 +53,8 @@ class HomeSpacesPresenter(
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,
canCreateSpaces = canCreateSpaces,
// TODO enable once we can link to the screen to explore public spaces
canExploreSpaces = false,
eventSink = ::handleEvent,
)
}

View file

@ -19,6 +19,7 @@ data class HomeSpacesState(
val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean,
val canCreateSpaces: Boolean,
val canExploreSpaces: Boolean,
val eventSink: (HomeSpacesEvents) -> Unit,
)

View file

@ -37,6 +37,11 @@ open class HomeSpacesStateProvider : PreviewParameterProvider<HomeSpacesState> {
spaceRooms = aListOfSpaceRooms(),
canCreateSpaces = false,
),
aHomeSpacesState(
space = CurrentSpace.Root,
spaceRooms = emptyList(),
canCreateSpaces = true,
),
)
}
@ -46,6 +51,7 @@ internal fun aHomeSpacesState(
seenSpaceInvites: Set<RoomId> = emptySet(),
hideInvitesAvatar: Boolean = false,
canCreateSpaces: Boolean = true,
canExploreSpaces: Boolean = true,
eventSink: (HomeSpacesEvents) -> Unit = {},
) = HomeSpacesState(
space = space,
@ -53,6 +59,7 @@ internal fun aHomeSpacesState(
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
canCreateSpaces = canCreateSpaces,
canExploreSpaces = canExploreSpaces,
eventSink = eventSink,
)

View file

@ -8,23 +8,40 @@
package io.element.android.features.home.impl.spaces
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.ui.components.SpaceHeaderRootView
import io.element.android.libraries.matrix.ui.components.SpaceHeaderView
import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@Composable
@ -32,56 +49,119 @@ fun HomeSpacesView(
state: HomeSpacesState,
lazyListState: LazyListState,
onSpaceClick: (RoomId) -> Unit,
onCreateSpaceClick: () -> Unit,
onExploreClick: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier,
state = lazyListState
) {
val space = state.space
when (space) {
CurrentSpace.Root -> {
item {
SpaceHeaderRootView(numberOfSpaces = state.spaceRooms.size)
if (state.canCreateSpaces && state.spaceRooms.isEmpty()) {
EmptySpaceHomeView(
modifier = modifier,
onCreateSpaceClick = onCreateSpaceClick,
onExploreClick = onExploreClick,
canExploreSpaces = state.canExploreSpaces,
)
} else {
LazyColumn(
modifier = modifier,
state = lazyListState
) {
val space = state.space
when (space) {
CurrentSpace.Root -> {
item {
SpaceHeaderRootView(numberOfSpaces = state.spaceRooms.size)
}
}
is CurrentSpace.Space -> {
item {
SpaceHeaderView(
avatarData = space.spaceRoom.getAvatarData(AvatarSize.SpaceHeader),
name = space.spaceRoom.displayName,
topic = space.spaceRoom.topic,
visibility = space.spaceRoom.visibility,
heroes = space.spaceRoom.heroes.toImmutableList(),
numberOfMembers = space.spaceRoom.numJoinedMembers,
)
}
}
}
is CurrentSpace.Space -> item {
SpaceHeaderView(
avatarData = space.spaceRoom.getAvatarData(AvatarSize.SpaceHeader),
name = space.spaceRoom.displayName,
topic = space.spaceRoom.topic,
visibility = space.spaceRoom.visibility,
heroes = space.spaceRoom.heroes.toImmutableList(),
numberOfMembers = space.spaceRoom.numJoinedMembers,
)
}
}
item {
HorizontalDivider()
}
itemsIndexed(
items = state.spaceRooms,
key = { _, spaceRoom -> spaceRoom.roomId }
) { index, spaceRoom ->
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
hideAvatars = isInvitation && state.hideInvitesAvatar,
onClick = {
onSpaceClick(spaceRoom.roomId)
},
onLongClick = {
// TODO
},
)
if (index != state.spaceRooms.lastIndex) {
item {
HorizontalDivider()
}
itemsIndexed(
items = state.spaceRooms,
key = { _, spaceRoom -> spaceRoom.roomId }
) { index, spaceRoom ->
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
hideAvatars = isInvitation && state.hideInvitesAvatar,
onClick = {
onSpaceClick(spaceRoom.roomId)
},
onLongClick = {
// TODO
},
)
if (index != state.spaceRooms.lastIndex) {
HorizontalDivider()
}
}
}
}
}
@Composable
private fun EmptySpaceHomeView(
onCreateSpaceClick: () -> Unit,
onExploreClick: () -> Unit,
canExploreSpaces: Boolean,
modifier: Modifier = Modifier,
) {
HeaderFooterPage(
modifier = modifier,
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 16.dp, start = 40.dp, end = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BigIcon(
style = BigIcon.Style.Default(CompoundIcons.SpaceSolid())
)
Text(
text = stringResource(CommonStrings.screen_space_list_empty_state_title),
style = ElementTheme.typography.fontHeadingLgBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
}
},
footer = {
ButtonColumnMolecule {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_create_space),
onClick = onCreateSpaceClick,
)
if (canExploreSpaces) {
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_explore_public_spaces),
onClick = onExploreClick,
)
}
}
}
) {
}
}
@PreviewsDayNight
@Composable
internal fun HomeSpacesViewPreview(
@ -91,6 +171,7 @@ internal fun HomeSpacesViewPreview(
state = state,
lazyListState = rememberLazyListState(),
onSpaceClick = {},
modifier = Modifier,
onCreateSpaceClick = {},
onExploreClick = {},
)
}

View file

@ -50,6 +50,7 @@ Du har ingen ulæste beskeder!"</string>
<string name="screen_roomlist_mark_as_read">"Marker som læst"</string>
<string name="screen_roomlist_mark_as_unread">"Marker som ulæst"</string>
<string name="screen_roomlist_tombstoned_room_description">"Dette rum er blevet opgraderet"</string>
<string name="screen_roomlist_your_spaces">"Dine grupper"</string>
<string name="session_verification_banner_message">"Det ser ud til, at du bruger en ny enhed. Bekræft med en anden enhed for at få adgang til dine krypterede meddelelser."</string>
<string name="session_verification_banner_title">"Bekræft, at det er dig"</string>
</resources>

View file

@ -50,6 +50,7 @@ Du hast keine ungelesenen Nachrichten!"</string>
<string name="screen_roomlist_mark_as_read">"Als gelesen markieren"</string>
<string name="screen_roomlist_mark_as_unread">"Als ungelesen markieren"</string>
<string name="screen_roomlist_tombstoned_room_description">"Die Chat-Version wurde aktualisiert"</string>
<string name="screen_roomlist_your_spaces">"Deine Spaces"</string>
<string name="session_verification_banner_message">"Es sieht aus, als würdest du ein neues Gerät verwenden. Verifiziere es mit einem anderen Gerät, damit du auf deine verschlüsselten Nachrichten zugreifen kannst."</string>
<string name="session_verification_banner_title">"Verifiziere deine Identität"</string>
</resources>

View file

@ -50,6 +50,7 @@ Vous navez plus de messages non-lus !"</string>
<string name="screen_roomlist_mark_as_read">"Marquer comme lu"</string>
<string name="screen_roomlist_mark_as_unread">"Marquer comme non lu"</string>
<string name="screen_roomlist_tombstoned_room_description">"Ce salon a été mis à niveau."</string>
<string name="screen_roomlist_your_spaces">"Vos espaces"</string>
<string name="session_verification_banner_message">"Il semblerait que vous utilisiez un nouvel appareil. Vérifiez la session avec un autre de vos appareils pour accéder à vos messages chiffrés."</string>
<string name="session_verification_banner_title">"Vérifier que cest bien vous"</string>
</resources>

View file

@ -50,6 +50,7 @@ Nincs olvasatlan üzenete!"</string>
<string name="screen_roomlist_mark_as_read">"Megjelölés olvasottként"</string>
<string name="screen_roomlist_mark_as_unread">"Megjelölés olvasatlanként"</string>
<string name="screen_roomlist_tombstoned_room_description">"A szoba verzióját frissítették"</string>
<string name="screen_roomlist_your_spaces">"Saját terek"</string>
<string name="session_verification_banner_message">"Úgy tűnik, hogy új eszközt használ. Ellenőrizze egy másik eszközzel, hogy a továbbiakban elérje a titkosított üzeneteket."</string>
<string name="session_verification_banner_title">"Ellenőrizze, hogy Ön az"</string>
</resources>

View file

@ -50,6 +50,7 @@ Você não tem nenhuma mensagem não lida!"</string>
<string name="screen_roomlist_mark_as_read">"Marcar como lida"</string>
<string name="screen_roomlist_mark_as_unread">"Marcar como não lida"</string>
<string name="screen_roomlist_tombstoned_room_description">"Esta sala foi atualizada"</string>
<string name="screen_roomlist_your_spaces">"Seus espaços"</string>
<string name="session_verification_banner_message">"Parece que você está usando um novo dispositivo. Verifique com outro dispositivo para acessar suas mensagens criptografadas."</string>
<string name="session_verification_banner_title">"Verifique se é você"</string>
</resources>

View file

@ -50,6 +50,7 @@ Nemáte žiadne neprečítané správy!"</string>
<string name="screen_roomlist_mark_as_read">"Označiť ako prečítané"</string>
<string name="screen_roomlist_mark_as_unread">"Označiť ako neprečítané"</string>
<string name="screen_roomlist_tombstoned_room_description">"Táto miestnosť bola aktualizovaná"</string>
<string name="screen_roomlist_your_spaces">"Vaše priestory"</string>
<string name="session_verification_banner_message">"Vyzerá to tak, že používate nové zariadenie. Overte svoj prístup k zašifrovaným správam pomocou vášho druhého zariadenia."</string>
<string name="session_verification_banner_title">"Overte, že ste to vy"</string>
</resources>

View file

@ -50,6 +50,7 @@ You dont have any unread messages!"</string>
<string name="screen_roomlist_mark_as_read">"Mark as read"</string>
<string name="screen_roomlist_mark_as_unread">"Mark as unread"</string>
<string name="screen_roomlist_tombstoned_room_description">"This room has been upgraded"</string>
<string name="screen_roomlist_your_spaces">"Your spaces"</string>
<string name="session_verification_banner_message">"Looks like youre using a new device. Verify with another device to access your encrypted messages."</string>
<string name="session_verification_banner_title">"Verify its you"</string>
</resources>

View file

@ -173,7 +173,7 @@ class HomePresenterTest {
}
@Test
fun `present - NavigationBar is hidden when the last space is left`() = runTest {
fun `present - NavigationBar is hidden when the last space is left when the user can't create new spaces`() = runTest {
val homeSpacesPresenter = MutablePresenter(aHomeSpacesState())
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
@ -193,7 +193,7 @@ class HomePresenterTest {
val spaceState = awaitItem()
assertThat(spaceState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
// The last space is left
homeSpacesPresenter.updateState(aHomeSpacesState(spaceRooms = emptyList()))
homeSpacesPresenter.updateState(aHomeSpacesState(spaceRooms = emptyList(), canCreateSpaces = false))
skipItems(1)
val finalState = awaitItem()
// We are back to Chats

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -122,13 +123,18 @@ fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(),
): RoomListSearchPresenter {
return RoomListSearchPresenter(
dataSource = RoomListSearchDataSource(
roomListService = roomListService,
roomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = FakeDateFormatter(),
roomLatestEventFormatter = FakeRoomLatestEventFormatter(),
),
coroutineDispatchers = testCoroutineDispatchers(),
),
dataSourceFactory = object : RoomListSearchDataSource.Factory {
override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource {
return RoomListSearchDataSource(
roomListService = roomListService,
roomSummaryFactory = aRoomListRoomSummaryFactory(
dateFormatter = FakeDateFormatter(),
roomLatestEventFormatter = FakeRoomLatestEventFormatter(),
),
coroutineDispatchers = testCoroutineDispatchers(),
coroutineScope = coroutineScope,
)
}
}
)
}

View file

@ -13,6 +13,5 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents {
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
data class UpdateSearchQuery(val query: String) : DefaultInvitePeopleEvents
data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents
}

View file

@ -8,9 +8,12 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
@ -39,6 +42,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.filterMembers
import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.usersearch.api.UserRepository
@ -47,11 +51,16 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private const val MAX_SUGGESTIONS_COUNT = 5
@AssistedInject
class DefaultInvitePeoplePresenter(
@Assisted private val joinedRoom: JoinedRoom?,
@ -73,11 +82,39 @@ class DefaultInvitePeoplePresenter(
val roomMembers = remember { mutableStateOf<AsyncData<ImmutableList<RoomMember>>>(AsyncData.Loading()) }
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
val queryState = rememberTextFieldState()
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
if (roomMembers.value.isSuccess()) {
val activeMemberIds = roomMembers.value.dataOrNull().orEmpty()
.filter { it.membership.isActive() }
.mapTo(mutableSetOf()) { it.userId }
value = matrixClient.getRecentDirectRooms()
.filterNot { it.matrixUser.userId in activeMemberIds }
.take(MAX_SUGGESTIONS_COUNT)
.toList()
}
}
// Convert recent direct rooms to InvitableUser for display
val suggestions by remember {
derivedStateOf {
recentDirectRooms.map { recentDirectRoom ->
InvitableUser(
matrixUser = recentDirectRoom.matrixUser,
isSelected = recentDirectRoom.matrixUser in selectedUsers.value,
isAlreadyJoined = false,
isAlreadyInvited = false,
isUnresolved = false,
)
}.toImmutableList()
}
}
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
if (joinedRoom == null) {
val result = matrixClient.getJoinedRoom(roomId)
@ -94,6 +131,7 @@ class DefaultInvitePeoplePresenter(
fetchMembers(it, roomMembers)
}
}
val searchQuery = queryState.text.toString()
LaunchedEffect(searchQuery, roomMembers) {
performSearch(
searchResults = searchResults,
@ -108,16 +146,15 @@ class DefaultInvitePeoplePresenter(
when (event) {
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> {
searchActive = event.active
searchQuery = ""
}
is DefaultInvitePeopleEvents.UpdateSearchQuery -> {
searchQuery = event.query
if (!event.active) {
queryState.clearText()
}
}
is DefaultInvitePeopleEvents.ToggleUser -> {
selectedUsers.toggleUser(event.user)
searchResults.toggleUser(event.user)
// suggestions will automatically update via derivedStateOf when selectedUsers changes
}
is InvitePeopleEvents.SendInvites -> {
room.dataOrNull()?.let {
@ -126,7 +163,7 @@ class DefaultInvitePeoplePresenter(
}
is InvitePeopleEvents.CloseSearch -> {
searchActive = false
searchQuery = ""
queryState.clearText()
}
}
}
@ -135,11 +172,12 @@ class DefaultInvitePeoplePresenter(
room = room.map { },
canInvite = selectedUsers.value.isNotEmpty() && !sendInvitesAction.value.isLoading(),
selectedUsers = selectedUsers.value,
searchQuery = searchQuery,
searchQuery = queryState,
isSearchActive = searchActive,
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,
sendInvitesAction = sendInvitesAction.value,
suggestions = suggestions,
eventSink = ::handleEvent,
)
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
@ -19,11 +20,12 @@ import kotlinx.collections.immutable.ImmutableList
data class DefaultInvitePeopleState(
val room: AsyncData<Unit>,
override val canInvite: Boolean,
val searchQuery: String,
val searchQuery: TextFieldState,
val showSearchLoader: Boolean,
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
val suggestions: ImmutableList<InvitableUser>,
override val eventSink: (InvitePeopleEvents) -> Unit
) : InvitePeopleState

View file

@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
@ -101,16 +102,20 @@ private fun aDefaultInvitePeopleState(
isSearchActive: Boolean = false,
showSearchLoader: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
suggestions: List<InvitableUser> = aMatrixUserList()
.take(5)
.map { user -> anInvitableUser(matrixUser = user, isSelected = user in selectedUsers) },
): DefaultInvitePeopleState {
return DefaultInvitePeopleState(
room = room,
canInvite = canInvite,
searchQuery = searchQuery,
searchQuery = TextFieldState(initialText = searchQuery),
searchResults = searchResults,
selectedUsers = selectedUsers,
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
sendInvitesAction = sendInvitesAction,
suggestions = suggestions.toImmutableList(),
eventSink = {},
)
}

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -31,6 +32,8 @@ import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
@ -82,9 +85,13 @@ private fun InvitePeopleContentView(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
fun toggleUser(user: MatrixUser) {
state.eventSink(DefaultInvitePeopleEvents.ToggleUser(user))
}
InvitePeopleSearchBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
queryState = state.searchQuery,
showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers,
state = state.searchResults,
@ -96,18 +103,45 @@ private fun InvitePeopleContentView(
)
)
},
onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) },
onToggleUser = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
onToggleUser = ::toggleUser,
)
if (!state.isSearchActive) {
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = state.selectedUsers,
autoScroll = true,
onUserRemove = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
contentPadding = PaddingValues(16.dp),
)
if (state.selectedUsers.isNotEmpty()) {
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = state.selectedUsers,
autoScroll = true,
onUserRemove = ::toggleUser,
contentPadding = PaddingValues(all = 16.dp),
)
}
if (state.suggestions.isNotEmpty()) {
LazyColumn {
item {
ListSectionHeader(
title = stringResource(id = CommonStrings.common_suggestions),
hasDivider = false,
)
}
itemsIndexed(state.suggestions) { index, invitableUser ->
CheckableUserRow(
checked = invitableUser.isSelected,
onCheckedChange = {
state.eventSink(DefaultInvitePeopleEvents.ToggleUser(invitableUser.matrixUser))
},
data = CheckableUserRowData.Resolved(
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem),
name = invitableUser.matrixUser.getBestName(),
subtext = invitableUser.matrixUser.userId.value,
),
)
if (index < state.suggestions.lastIndex) {
HorizontalDivider()
}
}
}
}
}
}
}
@ -115,20 +149,18 @@ private fun InvitePeopleContentView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InvitePeopleSearchBar(
query: String,
queryState: TextFieldState,
state: SearchBarResultState<ImmutableList<InvitableUser>>,
showLoader: Boolean,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
onTextChange: (String) -> Unit,
onToggleUser: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone),
) {
SearchBar(
query = query,
onQueryChange = onTextChange,
queryState = queryState,
active = active,
onActiveChange = onActiveChange,
modifier = modifier,
@ -140,7 +172,7 @@ private fun InvitePeopleSearchBar(
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemove = onToggleUser,
contentPadding = PaddingValues(16.dp),
contentPadding = PaddingValues(all = 16.dp),
)
}
},

View file

@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invitepeople.api.InvitePeopleEvents
@ -17,6 +18,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
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.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -26,7 +28,9 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomMemberList
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@ -65,31 +69,34 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.canInvite).isFalse()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchQuery.text.toString()).isEmpty()
skipItems(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - updates search active state`() = runTest {
val presenter = createDefaultInvitePeoplePresenter()
val presenter = createDefaultInvitePeoplePresenter(
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.OnSearchActiveChanged(true))
val resultState = awaitItem()
val resultState = awaitItemAsDefault()
assertThat(resultState.isSearchActive).isTrue()
resultState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
assertThat(awaitItemAsDefault().searchQuery).isEqualTo("some query")
resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
resultState.eventSink(InvitePeopleEvents.CloseSearch)
skipItems(1)
skipItems(2)
awaitItemAsDefault().also {
assertThat(it.isSearchActive).isFalse()
assertThat(it.searchQuery).isEmpty()
assertThat(it.searchQuery.text.toString()).isEmpty()
}
cancelAndIgnoreRemainingEvents()
}
}
@ -101,8 +108,8 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
val initialState = awaitItemAsDefault()
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true))
skipItems(3)
@ -126,10 +133,10 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@ -179,10 +186,10 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = coroutineDispatchers,
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@ -239,10 +246,10 @@ internal class DefaultInvitePeoplePresenterTest {
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@ -275,7 +282,7 @@ internal class DefaultInvitePeoplePresenterTest {
val repository = FakeUserRepository()
val presenter = createDefaultInvitePeoplePresenter(
userRepository = repository,
coroutineDispatchers = testCoroutineDispatchers()
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
@ -306,14 +313,14 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
val selectedUser = aMatrixUser()
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser))
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@ -344,13 +351,13 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test {
val initialState = awaitItem()
val initialState = awaitItemAsDefault()
skipItems(1)
val selectedUser = aMatrixUser()
// Given a query is made
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query"))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
@ -519,6 +526,85 @@ internal class DefaultInvitePeoplePresenterTest {
}
}
@Test
fun `present - suggestions are loaded from recent direct rooms`() = runTest {
val dmRoomId = RoomId("!dm_room:server.org")
val otherUserId = UserId("@frank:server.org")
val matrixClient = FakeMatrixClient(sessionId = A_USER_ID).apply {
// Track the DM room as recently visited
trackRecentlyVisitedRoom(dmRoomId)
// Set up a DM room with the other user
givenGetRoomResult(
dmRoomId,
FakeBaseRoom(
sessionId = A_USER_ID,
roomId = dmRoomId,
initialRoomInfo = aRoomInfo(
id = dmRoomId,
isDirect = true,
activeMembersCount = 2,
currentUserMembership = CurrentUserMembership.JOINED,
),
getDirectRoomMemberResult = { aRoomMember(userId = otherUserId, displayName = "Frank") }
)
)
}
val presenter = createDefaultInvitePeoplePresenter(
matrixClient = matrixClient,
// Use empty room members so the suggestion doesn't get filtered
roomMembersState = RoomMembersState.Ready(persistentListOf()),
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
)
presenter.test {
skipItems(2)
val state = awaitItemAsDefault()
assertThat(state.suggestions).hasSize(1)
assertThat(state.suggestions.first().matrixUser.userId).isEqualTo(otherUserId)
assertThat(state.suggestions.first().isSelected).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - suggestions filters out existing room members`() = runTest {
val dmRoomId = RoomId("!dm_room:server.org")
val alreadyJoinedUserId = UserId("@frank:server.org")
val matrixClient = FakeMatrixClient(sessionId = A_USER_ID).apply {
trackRecentlyVisitedRoom(dmRoomId)
givenGetRoomResult(
dmRoomId,
FakeBaseRoom(
sessionId = A_USER_ID,
roomId = dmRoomId,
initialRoomInfo = aRoomInfo(
id = dmRoomId,
isDirect = true,
activeMembersCount = 2,
currentUserMembership = CurrentUserMembership.JOINED,
),
getDirectRoomMemberResult = { aRoomMember(userId = alreadyJoinedUserId, displayName = "Frank") }
)
)
}
// The user in the suggestion is already a member of the target room
val presenter = createDefaultInvitePeoplePresenter(
matrixClient = matrixClient,
roomMembersState = RoomMembersState.Ready(
persistentListOf(
aRoomMember(userId = alreadyJoinedUserId, membership = RoomMembershipState.JOIN)
)
),
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
)
presenter.test {
skipItems(1)
// The suggestion should be filtered out because the user is already a room member
val state = awaitItemAsDefault()
assertThat(state.suggestions).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun FakeUserRepository.emitStateWithUsers(
users: List<MatrixUser>,
isSearching: Boolean = false

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Scan QR-koden"</string>
<string name="screen_link_new_device_desktop_step1">"Åbn %1$s på en bærbar eller stationær computer"</string>
<string name="screen_link_new_device_desktop_step3">"Scan QR-koden med denne enhed"</string>
<string name="screen_link_new_device_desktop_submit">"Klar til at scanne"</string>
<string name="screen_link_new_device_desktop_title">"Åbn %1$s på en stationær computer for at få QR-koden"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Tallene stemmer ikke overens"</string>
<string name="screen_link_new_device_enter_number_notice">"Indtast 2-cifret kode"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Dette vil bekræfte, at forbindelsen til din anden enhed er sikker."</string>
<string name="screen_link_new_device_enter_number_title">"Indtast nummeret, der vises på din anden enhed"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Din kontoudbyder understøtter ikke %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s understøttes ikke"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Din kontoudbyder understøtter ikke login på en ny enhed med en QR-kode."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR-kode understøttes ikke"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Login blev annulleret på den anden enhed."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Anmodning om login annulleret"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Login er udløbet. Prøv venligst igen."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Login blev ikke afsluttet i tide"</string>
<string name="screen_link_new_device_mobile_step1">"Åbn %1$s på den anden enhed"</string>
<string name="screen_link_new_device_mobile_step2">"Vælg %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"\"Log ind med QR-kode\""</string>
<string name="screen_link_new_device_mobile_step3">"Scan QR-koden vist her med den anden enhed"</string>
<string name="screen_link_new_device_mobile_title">"Åbn %1$s på den anden enhed"</string>
<string name="screen_link_new_device_root_desktop_computer">"Stationær computer"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Indlæser QR-kode…"</string>
<string name="screen_link_new_device_root_mobile_device">"Mobil enhed"</string>
<string name="screen_link_new_device_root_title">"Hvilken type enhed vil du linke?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Prøv igen, og sørg for, at du har indtastet den 2-cifrede kode korrekt. Hvis tallene stadig ikke stemmer overens, skal du kontakte din kontoudbyder."</string>
<string name="screen_link_new_device_wrong_number_title">"Tallene stemmer ikke overens"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Der kunne ikke oprettes en sikker forbindelse til den nye enhed. Dine eksisterende enheder er stadig sikre, og du behøver ikke bekymre dig om dem."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Hvad nu?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Prøv at logge ind igen med en QR-kode, hvis dette skyldtes et netværksproblem"</string>
@ -21,6 +38,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Anmodning om login annulleret"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Login blev afvist på den anden enhed."</string>
<string name="screen_qr_code_login_error_declined_title">"Login afvist"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Du behøver ikke gøre andet."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Din anden enhed er allerede logget ind"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Login er udløbet. Prøv venligst igen."</string>
<string name="screen_qr_code_login_error_expired_title">"Login blev ikke afsluttet i tide"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Din anden enhed understøtter ikke at logge ind på %s med en QR-kode.

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Olvassa be a QR-kódot"</string>
<string name="screen_link_new_device_desktop_step1">"Nyissa meg az %1$s alkalmazást egy laptopon vagy asztali számítógépen."</string>
<string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
<string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string>
<string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string>
<string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string>
<string name="screen_link_new_device_enter_number_title">"Adja meg a másik eszközön megjelenő számot"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"A fiókszolgáltatója nem támogatja az %1$s-et."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"Az %1$s nem támogatott"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Fiókszolgáltatója nem támogatja a QR-kóddal történő bejelentkezést egy új eszközre."</string>
<string name="screen_link_new_device_error_not_supported_title">"A QR-kód nem támogatott"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"A bejelentkezést megszakították a másik eszközön."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Bejelentkezési kérés törölve"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"A bejelentkezés lejárt. Próbálja újra."</string>
<string name="screen_link_new_device_error_request_timeout_title">"A bejelentkezés nem fejeződött be időben"</string>
<string name="screen_link_new_device_mobile_step1">"Nyissa meg a másik eszközön ezt: %1$s"</string>
<string name="screen_link_new_device_mobile_step2">"Válassza ezt: %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"„Bejelentkezés QR-kóddal”"</string>
<string name="screen_link_new_device_mobile_step3">"Olvassa le az itt látható QR-kódot a másik eszközzel"</string>
<string name="screen_link_new_device_mobile_title">"Nyissa meg a másik eszközön ezt: %1$s"</string>
<string name="screen_link_new_device_root_desktop_computer">"Asztali számítógép"</string>
<string name="screen_link_new_device_root_loading_qr_code">"QR-kód betöltése…"</string>
<string name="screen_link_new_device_root_mobile_device">"Mobil eszköz"</string>
<string name="screen_link_new_device_root_title">"Milyen típusú eszközt szeretne összekapcsolni?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Próbálja meg újra, és ellenőrizze, hogy helyesen adta meg a 2 számjegyű kódot. Ha a számok továbbra sem egyeznek, vegye fel a kapcsolatot a fiókszolgáltatójával."</string>
<string name="screen_link_new_device_wrong_number_title">"A számok nem egyeznek"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Most mi lesz?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt."</string>
@ -21,6 +38,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Bejelentkezési kérés törölve"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"A bejelentkezést elutasították a másik eszközön."</string>
<string name="screen_qr_code_login_error_declined_title">"A bejelentkezés elutasítva"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Semmi mást nem kell tennie."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"A másik eszköz már be van jelentkezve"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"A bejelentkezés lejárt. Próbálja újra."</string>
<string name="screen_qr_code_login_error_expired_title">"A bejelentkezés nem fejeződött be időben"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"A másik eszköz nem támogatja QR-kóddal történő bejelentkezést az %sbe.

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Сканировать QR-код"</string>
<string name="screen_link_new_device_desktop_step1">"Откройте %1$s на ноутбуке или компьютере"</string>
<string name="screen_link_new_device_desktop_step3">"Отсканируйте QR-код с помощью этого устройства"</string>
<string name="screen_link_new_device_desktop_submit">"Готово к сканированию"</string>
<string name="screen_link_new_device_desktop_title">"Открой %1$s на компьютере, чтобы получить QR-код"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Цифры не совпадают"</string>
<string name="screen_link_new_device_enter_number_notice">"Введите 2-значный код"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Это позволит убедиться в безопасности соединения с другим вашим устройством."</string>
<string name="screen_link_new_device_enter_number_title">"Введите номер, отображаемый на другом устройстве"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Поставщик учетной записи не поддерживает %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s не поддерживается"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Поставщик учетной записи не поддерживает вход на новое устройство с помощью QR-кода."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR-код не поддерживается"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Вход на другом устройстве был отменен."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Запрос на вход отменен"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Срок действия входа истек. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Вход в систему не был выполнен вовремя"</string>
<string name="screen_link_new_device_mobile_step1">"Открыть %1$s на другом устройстве"</string>
<string name="screen_link_new_device_mobile_step2">"Выберите %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"«Вход с помощью QR-кода»"</string>
<string name="screen_link_new_device_mobile_step3">"Отсканируйте показанный здесь QR-код на другом устройстве."</string>
<string name="screen_link_new_device_mobile_title">"Открыть %1$s на другом устройстве"</string>
<string name="screen_link_new_device_root_desktop_computer">"Настольный компьютер"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Загрузка QR-кода…"</string>
<string name="screen_link_new_device_root_mobile_device">"Мобильное устройство"</string>
<string name="screen_link_new_device_root_title">"Какой тип устройства вы хотите подключить?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Пожалуйста, попробуйте еще раз и убедитесь, что вы правильно ввели 2-значный код. Если цифры по-прежнему не совпадают, обратитесь к своему поставщику услуг."</string>
<string name="screen_link_new_device_wrong_number_title">"Цифры не совпадают"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Что теперь?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Попробуйте снова войти в систему с помощью QR-кода, если это была проблема с соединением"</string>
@ -21,6 +38,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Запрос на вход отменен"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Вход в систему был отклонен на другом устройстве."</string>
<string name="screen_qr_code_login_error_declined_title">"Вход отклонен"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Больше ничего не нужно делать."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Вход уже выполнен на другом устройстве"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Срок действия входа истек. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_qr_code_login_error_expired_title">"Вход в систему не был выполнен вовремя"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Другое устройство не поддерживает вход в %s с помощью QR-кода.

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_link_new_device_desktop_scanning_title">"Naskenovať QR kód"</string>
<string name="screen_link_new_device_desktop_step1">"Otvorte %1$s na notebooku alebo stolnom počítači"</string>
<string name="screen_link_new_device_desktop_step3">"Naskenujte QR kód pomocou tohto zariadenia"</string>
<string name="screen_link_new_device_desktop_submit">"Pripravené na skenovanie"</string>
<string name="screen_link_new_device_desktop_title">"Otvorte %1$s na stolnom počítači, aby ste získali QR kód"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Čísla sa nezhodujú"</string>
<string name="screen_link_new_device_enter_number_notice">"Zadajte 2-miestny kód"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Týmto sa overí, že pripojenie k vášmu druhému zariadeniu je bezpečné."</string>
<string name="screen_link_new_device_enter_number_title">"Zadajte číslo zobrazené na vašom druhom zariadení"</string>
<string name="screen_link_new_device_error_app_not_supported_subtitle">"Poskytovateľ vášho účtu nepodporuje %1$s."</string>
<string name="screen_link_new_device_error_app_not_supported_title">"%1$s nie je podporovaný"</string>
<string name="screen_link_new_device_error_not_supported_subtitle">"Poskytovateľ vášho účtu nepodporuje prihlásenie do nového zariadenia pomocou QR kódu."</string>
<string name="screen_link_new_device_error_not_supported_title">"QR kód nie je podporovaný"</string>
<string name="screen_link_new_device_error_request_cancelled_subtitle">"Prihlásenie bolo zrušené na druhom zariadení."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Žiadosť o prihlásenie bola zrušená"</string>
<string name="screen_link_new_device_error_request_timeout_subtitle">"Platnosť prihlásenia vypršala. Skúste to prosím znova."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Prihlásenie nebolo včas dokončené"</string>
<string name="screen_link_new_device_mobile_step1">"Otvorte %1$s na druhom zariadení"</string>
<string name="screen_link_new_device_mobile_step2">"Vyberte %1$s"</string>
<string name="screen_link_new_device_mobile_step2_action">"„Prihláste sa pomocou QR kódu“"</string>
<string name="screen_link_new_device_mobile_step3">"Naskenujte tu zobrazený QR kód pomocou druhého zariadenia"</string>
<string name="screen_link_new_device_mobile_title">"Otvorte %1$s na druhom zariadení"</string>
<string name="screen_link_new_device_root_desktop_computer">"Stolný počítač"</string>
<string name="screen_link_new_device_root_loading_qr_code">"Načítava sa QR kód…"</string>
<string name="screen_link_new_device_root_mobile_device">"Mobilné zariadenie"</string>
<string name="screen_link_new_device_root_title">"Aký typ zariadenia chcete prepojiť?"</string>
<string name="screen_link_new_device_wrong_number_subtitle">"Skúste to prosím znova a uistite sa, že ste zadali 2-ciferný kód správne. Ak sa čísla stále nezhodujú, kontaktujte poskytovateľa vášho účtu."</string>
<string name="screen_link_new_device_wrong_number_title">"Čísla sa nezhodujú"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Čo teraz?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou"</string>
@ -21,6 +38,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Žiadosť o prihlásenie bola zrušená"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Prihlásenie bolo zamietnuté na druhom zariadení."</string>
<string name="screen_qr_code_login_error_declined_title">"Prihlásenie bolo odmietnuté"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Nemusíte spraviť nič iné."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Vaše druhé zariadenie je už prihlásené"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Platnosť prihlásenia vypršala. Skúste to prosím znova."</string>
<string name="screen_qr_code_login_error_expired_title">"Prihlásenie nebolo včas dokončené"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Vaše druhé zariadenie nepodporuje prihlásenie do aplikácie %s pomocou QR kódu.

View file

@ -60,4 +60,6 @@ dependencies {
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
testImplementation(libs.androidx.camera.camera2)
testImplementation(libs.androidx.camera.lifecycle)
}

View file

@ -60,6 +60,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Anmodning om login annulleret"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Login blev afvist på den anden enhed."</string>
<string name="screen_qr_code_login_error_declined_title">"Login afvist"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Du behøver ikke gøre andet."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Din anden enhed er allerede logget ind"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Login er udløbet. Prøv venligst igen."</string>
<string name="screen_qr_code_login_error_expired_title">"Login blev ikke afsluttet i tide"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Din anden enhed understøtter ikke at logge ind på %s med en QR-kode.

View file

@ -60,6 +60,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Bejelentkezési kérés törölve"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"A bejelentkezést elutasították a másik eszközön."</string>
<string name="screen_qr_code_login_error_declined_title">"A bejelentkezés elutasítva"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Semmi mást nem kell tennie."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"A másik eszköz már be van jelentkezve"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"A bejelentkezés lejárt. Próbálja újra."</string>
<string name="screen_qr_code_login_error_expired_title">"A bejelentkezés nem fejeződött be időben"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"A másik eszköz nem támogatja QR-kóddal történő bejelentkezést az %sbe.

View file

@ -60,6 +60,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Запрос на вход отменен"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Вход в систему был отклонен на другом устройстве."</string>
<string name="screen_qr_code_login_error_declined_title">"Вход отклонен"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Больше ничего не нужно делать."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Вход уже выполнен на другом устройстве"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Срок действия входа истек. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_qr_code_login_error_expired_title">"Вход в систему не был выполнен вовремя"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Другое устройство не поддерживает вход в %s с помощью QR-кода.

View file

@ -60,6 +60,8 @@
<string name="screen_qr_code_login_error_cancelled_title">"Žiadosť o prihlásenie bola zrušená"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Prihlásenie bolo zamietnuté na druhom zariadení."</string>
<string name="screen_qr_code_login_error_declined_title">"Prihlásenie bolo odmietnuté"</string>
<string name="screen_qr_code_login_error_device_already_signed_in_subtitle">"Nemusíte spraviť nič iné."</string>
<string name="screen_qr_code_login_error_device_already_signed_in_title">"Vaše druhé zariadenie je už prihlásené"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Platnosť prihlásenia vypršala. Skúste to prosím znova."</string>
<string name="screen_qr_code_login_error_expired_title">"Prihlásenie nebolo včas dokončené"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Vaše druhé zariadenie nepodporuje prihlásenie do aplikácie %s pomocou QR kódu.

View file

@ -9,9 +9,11 @@
package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.activity.ComponentActivity
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
@ -20,6 +22,8 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@ -30,6 +34,19 @@ class QrCodeScanViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
private var provider: ProcessCameraProvider? = null
@Before
fun setup() {
val context = InstrumentationRegistry.getInstrumentation().context
provider = ProcessCameraProvider.getInstance(context).get()
}
@After
fun teardown() {
provider?.unbindAll()
}
@Test
fun `on back pressed - calls the expected callback`() {
ensureCalledOnce { callback ->

View file

@ -68,7 +68,6 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j)

View file

@ -13,13 +13,12 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvents
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
data class OnUserClicked(val user: MatrixUser) : MessagesEvents
data object Dismiss : MessagesEvents
data object MarkAsFullyReadAndExit : MessagesEvents
sealed interface MessagesEvent {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvent
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
}
enum class InviteDialogAction {

View file

@ -36,7 +36,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
@ -151,7 +151,7 @@ class MessagesNode(
activity: Activity,
darkTheme: Boolean,
url: String,
eventSink: (TimelineEvents) -> Unit,
eventSink: (TimelineEvent) -> Unit,
customTab: Boolean
) {
when (val permalink = permalinkParser.parse(url)) {
@ -178,12 +178,12 @@ class MessagesNode(
private fun handleRoomLinkClick(
roomLink: PermalinkData.RoomLink,
eventSink: (TimelineEvents) -> Unit,
eventSink: (TimelineEvent) -> Unit,
) {
if (room.matches(roomLink.roomIdOrAlias)) {
val eventId = roomLink.eventId
if (eventId != null) {
eventSink(TimelineEvents.FocusOnEvent(eventId))
eventSink(TimelineEvent.FocusOnEvent(eventId))
} else {
// Click on the same room, ignore
displaySameRoomToast()
@ -242,7 +242,7 @@ class MessagesNode(
val state = presenter.present()
BackHandler {
state.eventSink(MessagesEvents.MarkAsFullyReadAndExit)
state.eventSink(MessagesEvent.MarkAsFullyReadAndExit)
}
OnLifecycleEvent { _, event ->
@ -253,7 +253,7 @@ class MessagesNode(
}
MessagesView(
state = state,
onBackClick = { state.eventSink(MessagesEvents.MarkAsFullyReadAndExit) },
onBackClick = { state.eventSink(MessagesEvent.MarkAsFullyReadAndExit) },
onRoomDetailsClick = callback::navigateToRoomDetails,
onEventContentClick = { isLive, event ->
if (isLive) {
@ -305,7 +305,7 @@ class MessagesNode(
}
LaunchedEffect(focusedEventId) {
if (focusedEventId != null) {
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(focusedEventId!!))
state.timelineState.eventSink(TimelineEvent.FocusOnEvent(focusedEventId!!))
focusedEventId = null
}
}

View file

@ -27,10 +27,8 @@ import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
@ -38,7 +36,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
@ -76,6 +74,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -102,7 +101,6 @@ class MessagesPresenter(
@Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
private val historyVisibleStatePresenter: Presenter<HistoryVisibleState>,
private val linkPresenter: Presenter<LinkState>,
@Assisted private val actionListPresenter: Presenter<ActionListState>,
private val customReactionPresenter: Presenter<CustomReactionState>,
@ -154,7 +152,6 @@ class MessagesPresenter(
val timelineState = timelinePresenter.present()
val timelineProtectionState = timelineProtectionPresenter.present()
val identityChangeState = identityChangeStatePresenter.present()
val historyVisibleState = historyVisibleStatePresenter.present()
val actionListState = actionListPresenter.present()
val linkState = linkPresenter.present()
val customReactionState = customReactionPresenter.present()
@ -208,6 +205,16 @@ class MessagesPresenter(
val dmRoomMember by room.getDirectRoomMember(membersState)
val roomMemberIdentityStateChanges = identityChangeState.roomMemberIdentityStateChanges
val isKeyShareOnInviteEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
// The top bar should show a "history" icon if:
// * History sharing is enabled,
// * The room is encrypted, and:
// * The room's history_visibility allows future users to see content.
val showSharedHistoryIcon = isKeyShareOnInviteEnabled &&
roomInfo.isEncrypted == true &&
(roomInfo.historyVisibility == RoomHistoryVisibility.Shared ||
roomInfo.historyVisibility == RoomHistoryVisibility.WorldReadable)
LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) {
if (roomInfo.isEncrypted == true) {
val dmRoomMemberId = dmRoomMember?.userId
@ -221,9 +228,9 @@ class MessagesPresenter(
onPauseOrDispose {}
}
fun handleEvent(event: MessagesEvents) {
fun handleEvent(event: MessagesEvent) {
when (event) {
is MessagesEvents.HandleAction -> {
is MessagesEvent.HandleAction -> {
localCoroutineScope.handleTimelineAction(
action = event.action,
targetEvent = event.event,
@ -233,21 +240,20 @@ class MessagesPresenter(
timelineProtectionState = timelineProtectionState,
)
}
is MessagesEvents.ToggleReaction -> {
is MessagesEvent.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
}
is MessagesEvents.InviteDialogDismissed -> {
is MessagesEvent.InviteDialogDismissed -> {
hasDismissedInviteDialog = true
if (event.action == InviteDialogAction.Invite) {
localCoroutineScope.reinviteOtherUser(inviteProgress)
}
}
is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear)
is MessagesEvents.OnUserClicked -> {
is MessagesEvent.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
}
is MessagesEvents.MarkAsFullyReadAndExit -> coroutineScope.launch {
is MessagesEvent.MarkAsFullyReadAndExit -> coroutineScope.launch {
if (!markingAsReadAndExiting.getAndSet(true)) {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
Timber.w(it, "Failed to get latest event id to mark as fully read")
@ -277,7 +283,6 @@ class MessagesPresenter(
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
historyVisibleState = historyVisibleState,
linkState = linkState,
actionListState = actionListState,
customReactionState = customReactionState,
@ -292,6 +297,7 @@ class MessagesPresenter(
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
showSharedHistoryIcon = showSharedHistoryIcon,
successorRoom = roomInfo.successorRoom,
eventSink = ::handleEvent,
)
@ -529,7 +535,7 @@ class MessagesPresenter(
event: TimelineItem.Event,
timelineState: TimelineState,
) {
event.eventId?.let { timelineState.eventSink(TimelineEvents.EndPoll(it)) }
event.eventId?.let { timelineState.eventSink(TimelineEvent.EndPoll(it)) }
}
private suspend fun handleCopyLink(event: TimelineItem.Event) {

View file

@ -10,7 +10,6 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
@ -41,7 +40,6 @@ data class MessagesState(
val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,
val identityChangeState: IdentityChangeState,
val historyVisibleState: HistoryVisibleState,
val linkState: LinkState,
val actionListState: ActionListState,
val customReactionState: CustomReactionState,
@ -56,8 +54,10 @@ data class MessagesState(
val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?,
val roomMemberModerationState: RoomMemberModerationState,
/** Should the top bar include the "history" icon? */
val showSharedHistoryIcon: Boolean,
val successorRoom: SuccessorRoom?,
val eventSink: (MessagesEvents) -> Unit
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
}

View file

@ -14,8 +14,6 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.aRoomMemberIdentityStateChange
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
@ -28,11 +26,11 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess
import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
@ -92,15 +90,6 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange()))
),
aMessagesState(
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
historyVisibleState = aHistoryVisibleState(showAlert = true)
),
aMessagesState(
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())),
historyVisibleState = aHistoryVisibleState(showAlert = true)
)
)
}
@ -121,7 +110,6 @@ fun aMessagesState(
),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
identityChangeState: IdentityChangeState = anIdentityChangeState(),
historyVisibleState: HistoryVisibleState = aHistoryVisibleState(),
linkState: LinkState = aLinkState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(),
@ -132,8 +120,9 @@ fun aMessagesState(
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
dmUserVerificationState: IdentityState? = null,
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
showSharedHistoryIcon: Boolean = false,
successorRoom: SuccessorRoom? = null,
eventSink: (MessagesEvents) -> Unit = {},
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
roomId = RoomId("!id:domain"),
roomName = roomName,
@ -144,7 +133,6 @@ fun aMessagesState(
voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
historyVisibleState = historyVisibleState,
linkState = linkState,
timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
@ -160,6 +148,7 @@ fun aMessagesState(
pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
showSharedHistoryIcon = showSharedHistoryIcon,
successorRoom = successorRoom,
eventSink = eventSink,
)
@ -187,7 +176,7 @@ fun aUserEventPermissions(
fun aReactionSummaryState(
target: ReactionSummaryState.Summary? = null,
eventSink: (ReactionSummaryEvents) -> Unit = {}
eventSink: (ReactionSummaryEvent) -> Unit = {}
) = ReactionSummaryState(
target = target,
eventSink = eventSink,
@ -196,7 +185,7 @@ fun aReactionSummaryState(
fun aCustomReactionState(
target: CustomReactionState.Target = CustomReactionState.Target.None,
recentEmojis: ImmutableList<String> = persistentListOf(),
eventSink: (CustomReactionEvents) -> Unit = {},
eventSink: (CustomReactionEvent) -> Unit = {},
) = CustomReactionState(
target = target,
recentEmojis = recentEmojis,
@ -206,7 +195,7 @@ fun aCustomReactionState(
fun aReadReceiptBottomSheetState(
selectedEvent: TimelineItem.Event? = null,
eventSink: (ReadReceiptBottomSheetEvents) -> Unit = {},
eventSink: (ReadReceiptBottomSheetEvent) -> Unit = {},
) = ReadReceiptBottomSheetState(
selectedEvent = selectedEvent,
eventSink = eventSink,

View file

@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -51,12 +52,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListEvent
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.crypto.historyvisible.HistoryVisibleStateView
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.link.LinkEvents
import io.element.android.features.messages.impl.link.LinkEvent
import io.element.android.features.messages.impl.link.LinkView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.DisabledComposerView
@ -67,18 +67,18 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView
import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults
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.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.aGroupedEvents
import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineState
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.reactionsummary.ReactionSummaryEvents
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
@ -168,7 +168,7 @@ fun MessagesView(
Timber.v("OnMessageLongClicked= ${event.id}")
hidingKeyboard {
state.actionListState.eventSink(
ActionListEvents.ComputeForMessage(
ActionListEvent.ComputeForMessage(
event = event,
userEventPermissions = state.userEventPermissions,
)
@ -177,20 +177,20 @@ fun MessagesView(
}
fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
state.eventSink(MessagesEvents.HandleAction(action, event))
state.eventSink(MessagesEvent.HandleAction(action, event))
}
fun onEmojiReactionClick(emoji: String, event: TimelineItem.Event) {
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventOrTransactionId))
state.eventSink(MessagesEvent.ToggleReaction(emoji, event.eventOrTransactionId))
}
fun onEmojiReactionLongClick(emoji: String, event: TimelineItem.Event) {
if (event.eventId == null) return
state.reactionSummaryState.eventSink(ReactionSummaryEvents.ShowReactionSummary(event.eventId, event.reactionsState.reactions, emoji))
state.reactionSummaryState.eventSink(ReactionSummaryEvent.ShowReactionSummary(event.eventId, event.reactionsState.reactions, emoji))
}
fun onMoreReactionsClick(event: TimelineItem.Event) {
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
state.customReactionState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event))
}
val expandableState = rememberExpandableBottomSheetLayoutState()
@ -225,6 +225,7 @@ fun MessagesView(
heroes = state.heroes,
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
showSharedHistoryIcon = state.showSharedHistoryIcon,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,
@ -243,7 +244,7 @@ fun MessagesView(
onMessageLongClick = ::onMessageLongClick,
onUserDataClick = {
hidingKeyboard {
state.eventSink(MessagesEvents.OnUserClicked(it))
state.eventSink(MessagesEvent.OnUserClicked(it))
}
},
onLinkClick = { link, customTab ->
@ -251,19 +252,19 @@ fun MessagesView(
onLinkClick(link.url, true)
// Do not check those links, they are internal link only
} else {
state.linkState.eventSink(LinkEvents.OnLinkClick(link))
state.linkState.eventSink(LinkEvent.OnLinkClick(link))
}
},
onReactionClick = ::onEmojiReactionClick,
onReactionLongClick = ::onEmojiReactionLongClick,
onMoreReactionsClick = ::onMoreReactionsClick,
onReadReceiptClick = { event ->
state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event))
state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvent.EventSelected(event))
},
onSendLocationClick = onSendLocationClick,
onCreatePollClick = onCreatePollClick,
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
state.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Reply, targetEvent))
},
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
@ -300,7 +301,7 @@ fun MessagesView(
state = state,
onLinkClick = { url, customTab -> onLinkClick(url, customTab) },
onRoomSuccessorClick = { roomId ->
state.timelineState.eventSink(TimelineEvents.NavigateToPredecessorOrSuccessorRoom(roomId = roomId))
state.timelineState.eventSink(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(roomId = roomId))
},
)
},
@ -341,22 +342,43 @@ fun MessagesView(
maxBottomSheetContentHeight = maxComposerHeightPx.toDp(),
)
var endPollConfirmingEvent: TimelineItem.Event? by remember { mutableStateOf(null) }
if (endPollConfirmingEvent != null) {
ConfirmationDialog(
content = stringResource(id = CommonStrings.common_poll_end_confirmation),
onSubmitClick = {
endPollConfirmingEvent?.let { event ->
onActionSelected(TimelineItemAction.EndPoll, event)
}
endPollConfirmingEvent = null
},
onDismiss = { endPollConfirmingEvent = null },
)
}
ActionListView(
state = state.actionListState,
onSelectAction = ::onActionSelected,
onSelectAction = { action: TimelineItemAction, event: TimelineItem.Event ->
if (action == TimelineItemAction.EndPoll) {
endPollConfirmingEvent = event
} else {
onActionSelected(action, event)
}
},
onCustomReactionClick = { event ->
state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
state.customReactionState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event))
},
onEmojiReactionClick = ::onEmojiReactionClick,
onVerifiedUserSendFailureClick = { event ->
state.timelineState.eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
state.timelineState.eventSink(TimelineEvent.ComputeVerifiedUserSendFailure(event))
},
)
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, uniqueId))
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
}
)
@ -382,8 +404,8 @@ private fun ReinviteDialog(state: MessagesState) {
content = stringResource(id = R.string.screen_room_invite_again_alert_message),
cancelText = stringResource(id = CommonStrings.action_cancel),
submitText = stringResource(id = CommonStrings.action_invite),
onSubmitClick = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) },
onDismiss = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) }
onSubmitClick = { state.eventSink(MessagesEvent.InviteDialogDismissed(InviteDialogAction.Invite)) },
onDismiss = { state.eventSink(MessagesEvent.InviteDialogDismissed(InviteDialogAction.Cancel)) }
)
}
}
@ -467,7 +489,7 @@ private fun MessagesViewContent(
) {
fun focusOnPinnedEvent(eventId: EventId) {
state.timelineState.eventSink(
TimelineEvents.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
TimelineEvent.FocusOnEvent(eventId = eventId, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)
)
}
PinnedMessagesBannerView(
@ -497,17 +519,10 @@ private fun MessagesViewComposerBottomSheetContents(
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
if (state.identityChangeState.roomMemberIdentityStateChanges.isNotEmpty()) {
IdentityChangeStateView(
state = state.identityChangeState,
onLinkClick = onLinkClick,
)
} else {
HistoryVisibleStateView(
state = state.historyVisibleState,
onLinkClick = onLinkClick,
)
}
IdentityChangeStateView(
state = state.identityChangeState,
onLinkClick = onLinkClick,
)
}
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.VerificationViolation

View file

@ -11,10 +11,10 @@ package io.element.android.features.messages.impl.actionlist
import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface ActionListEvents {
data object Clear : ActionListEvents
sealed interface ActionListEvent {
data object Clear : ActionListEvent
data class ComputeForMessage(
val event: TimelineItem.Event,
val userEventPermissions: UserEventPermissions,
) : ActionListEvents
) : ActionListEvent
}

View file

@ -107,10 +107,10 @@ class DefaultActionListPresenter(
val isThreadsEnabled = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
fun handleEvent(event: ActionListEvents) {
fun handleEvent(event: ActionListEvent) {
when (event) {
ActionListEvents.Clear -> target.value = ActionListState.Target.None
is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(
ActionListEvent.Clear -> target.value = ActionListState.Target.None
is ActionListEvent.ComputeForMessage -> localCoroutineScope.computeForMessage(
timelineItem = event.event,
usersEventPermissions = event.userEventPermissions,
isDeveloperModeEnabled = isDeveloperModeEnabled,

View file

@ -16,7 +16,7 @@ import kotlinx.collections.immutable.ImmutableList
data class ActionListState(
val target: Target,
val eventSink: (ActionListEvents) -> Unit,
val eventSink: (ActionListEvent) -> Unit,
) {
@Immutable
sealed interface Target {

View file

@ -192,7 +192,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
fun anActionListState(
target: ActionListState.Target = ActionListState.Target.None,
eventSink: (ActionListEvents) -> Unit = {},
eventSink: (ActionListEvent) -> Unit = {},
) = ActionListState(
target = target,
eventSink = eventSink

View file

@ -118,7 +118,7 @@ fun ActionListView(
) {
if (targetItem == null) return
sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
state.eventSink(ActionListEvent.Clear)
onSelectAction(itemAction, targetItem)
}
}
@ -126,7 +126,7 @@ fun ActionListView(
fun onEmojiReactionClick(emoji: String) {
if (targetItem == null) return
sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
state.eventSink(ActionListEvent.Clear)
onEmojiReactionClick(emoji, targetItem)
}
}
@ -134,19 +134,19 @@ fun ActionListView(
fun onCustomReactionClick() {
if (targetItem == null) return
sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
state.eventSink(ActionListEvent.Clear)
onCustomReactionClick(targetItem)
}
}
fun onDismiss() {
state.eventSink(ActionListEvents.Clear)
state.eventSink(ActionListEvent.Clear)
}
fun onVerifiedUserSendFailureClick() {
if (targetItem == null) return
sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
state.eventSink(ActionListEvent.Clear)
onVerifiedUserSendFailureClick(targetItem)
}
}

View file

@ -8,8 +8,8 @@
package io.element.android.features.messages.impl.attachments.preview
sealed interface AttachmentsPreviewEvents {
data object SendAttachment : AttachmentsPreviewEvents
data object CancelAndDismiss : AttachmentsPreviewEvents
data object CancelAndClearSendState : AttachmentsPreviewEvents
sealed interface AttachmentsPreviewEvent {
data object SendAttachment : AttachmentsPreviewEvent
data object CancelAndDismiss : AttachmentsPreviewEvent
data object CancelAndClearSendState : AttachmentsPreviewEvent
}

View file

@ -98,7 +98,7 @@ class AttachmentsPreviewPresenter(
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
}
val mediaOptimizationSelectorState = mediaOptimizationSelectorPresenter.present()
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
val observableSendState = snapshotFlow { sendActionState.value }
@ -140,9 +140,9 @@ class AttachmentsPreviewPresenter(
}
}
fun handleEvent(event: AttachmentsPreviewEvents) {
fun handleEvent(event: AttachmentsPreviewEvent) {
when (event) {
is AttachmentsPreviewEvents.SendAttachment -> {
is AttachmentsPreviewEvent.SendAttachment -> {
ongoingSendAttachmentJob.value = coroutineScope.launch {
// If the media optimization selector is displayed, we need to wait for the user to select the options
// before we can pre-process the media.
@ -191,7 +191,7 @@ class AttachmentsPreviewPresenter(
}
}
}
AttachmentsPreviewEvents.CancelAndDismiss -> {
AttachmentsPreviewEvent.CancelAndDismiss -> {
displayFileTooLargeError = false
// Cancel media preprocessing and sending
@ -206,7 +206,7 @@ class AttachmentsPreviewPresenter(
sendActionState,
)
}
AttachmentsPreviewEvents.CancelAndClearSendState -> {
AttachmentsPreviewEvent.CancelAndClearSendState -> {
// Cancel media sending
ongoingSendAttachmentJob.value?.let {
it.cancel()

View file

@ -20,7 +20,7 @@ data class AttachmentsPreviewState(
val textEditorState: TextEditorState,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
val displayFileTooLargeError: Boolean,
val eventSink: (AttachmentsPreviewEvents) -> Unit
val eventSink: (AttachmentsPreviewEvent) -> Unit,
)
@Immutable

View file

@ -82,15 +82,15 @@ fun AttachmentsPreviewView(
modifier: Modifier = Modifier,
) {
fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
state.eventSink(AttachmentsPreviewEvent.SendAttachment)
}
fun postCancel() {
state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss)
state.eventSink(AttachmentsPreviewEvent.CancelAndDismiss)
}
fun postClearSendState() {
state.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState)
state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
}
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
@ -199,7 +199,7 @@ private fun AttachmentPreviewContent(
AlertDialog(
title = stringResource(CommonStrings.dialog_file_too_large_to_upload_title),
content = content,
onDismiss = { state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) },
onDismiss = { state.eventSink(AttachmentsPreviewEvent.CancelAndDismiss) },
)
}
}

View file

@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.historyvisible
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface HistoryVisibleAcknowledgementRepository {
fun hasAcknowledged(roomId: RoomId): Flow<Boolean>
suspend fun setAcknowledged(roomId: RoomId, value: Boolean)
}
@ContributesBinding(SessionScope::class)
class DefaultHistoryVisibleAcknowledgementRepository(
sessionId: SessionId,
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : HistoryVisibleAcknowledgementRepository {
val store =
sessionId.value.hash().take(16).let { hash ->
preferenceDataStoreFactory.create("elementx_historyvisible_$hash")
}
override fun hasAcknowledged(roomId: RoomId): Flow<Boolean> {
return store.data.map { prefs ->
val acknowledged = prefs[booleanPreferencesKey(roomId.value)] ?: false
acknowledged
}
}
override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) {
store.edit { prefs ->
prefs[booleanPreferencesKey(roomId.value)] = value
}
}
}

View file

@ -1,12 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.crypto.historyvisible
sealed interface HistoryVisibleEvent {
data object Acknowledge : HistoryVisibleEvent
}

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