Merge branch 'release/26.01.2' into main

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

View file

@ -36,7 +36,7 @@ jobs:
./tools/localazy/importSupportedLocalesFromLocalazy.py ./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py ./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings - 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: with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }} token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy commit-message: Sync Strings from Localazy

View file

@ -23,7 +23,7 @@ jobs:
- name: Run SAS String script - name: Run SAS String script
run: ./tools/sas/import_sas_strings.py run: ./tools/sas/import_sas_strings.py
- name: Create Pull Request for SAS Strings - 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: with:
commit-message: Sync SAS Strings commit-message: Sync SAS Strings
title: 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 Changes in Element X v26.01.0
============================= =============================

View file

@ -132,8 +132,23 @@ android {
optimization { optimization {
enable = true enable = true
keepRules { keepRules {
files.add(File(projectDir, "proguard-rules.pro")) files.add(File(projectDir, "common-proguard-rules.pro"))
files.add(getDefaultProguardFile("proguard-android-optimize.txt")) 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 # For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html # http://developer.android.com/guide/developing/tools/proguard.html
# Increase optimizations passes to 3
-optimizationpasses 3
# JNA # JNA
-dontwarn java.awt.* -dontwarn java.awt.*
-keep class com.sun.jna.** { *; } -keep class com.sun.jna.** { *; }
@ -41,7 +44,6 @@
static int windowAttachCount(android.view.View); static int windowAttachCount(android.view.View);
} }
# Keep LogSessionId class and related classes (https://github.com/androidx/media/issues/2535) # Keep LogSessionId class and related classes (https://github.com/androidx/media/issues/2535)
-keep class android.media.metrics.LogSessionId { *; } -keep class android.media.metrics.LogSessionId { *; }
-keep class android.media.metrics.** { *; } -keep class android.media.metrics.** { *; }
@ -66,10 +68,9 @@
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo -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 # 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,allowoptimization,allowshrinking class org.matrix.rustcomponents.sdk.** { *;}
-keep,allowshrinking class uniffi.** { *;} -keep,allowoptimization,allowshrinking class uniffi.** { *;}
-keep,allowshrinking class io.element.android.x.di.** { *; } -keep,allowoptimization,allowshrinking class io.element.android.x.di.** { *; }
-keepclasseswithmembernames,allowoptimization,allowshrinking class io.element.android.** { *; }
# Keep Metro classes # 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. * The maximum size of a single log file.
*/ */
const val MAX_LOG_CONTENT_SIZE = 100 * 1024 * 1024L 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())) 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 -> { is NavTarget.SecureBackup -> {
secureBackupEntryPoint.createNode( secureBackupEntryPoint.createNode(

View file

@ -214,6 +214,10 @@ class RoomFlowNode(
) )
} }
is NavTarget.JoinRoom -> { 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( val inputs = JoinRoomEntryPoint.Inputs(
roomId = navTarget.roomId, roomId = navTarget.roomId,
roomIdOrAlias = inputs.roomIdOrAlias, 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.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.LoadingRoomState import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory 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.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -53,6 +57,7 @@ class JoinedRoomFlowNode(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory, loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory,
private val analyticsService: AnalyticsService,
) : ) :
BaseFlowNode<JoinedRoomFlowNode.NavTarget>( BaseFlowNode<JoinedRoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -81,6 +86,11 @@ class JoinedRoomFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
loadingRoomStateStateFlow loadingRoomStateStateFlow
.map { .map {
it is LoadingRoomState.Loaded 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.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom 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.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.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.ActiveRoomsHolder
@ -106,8 +104,6 @@ class JoinedRoomLoadedFlowNode(
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
val parent = analyticsService.getLongRunningTransaction(OpenRoom)
analyticsService.startLongRunningTransaction(LoadMessagesUi, parent)
Timber.v("OnCreate => ${inputs.room.roomId}") Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room) activeRoomsHolder.addRoom(inputs.room)

View file

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

View file

@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.5.3") detektPlugins("io.nlopez.compose.rules:detekt:0.5.6")
detektPlugins(project(":tests:detekt-rules")) 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_item2">"Принимать или отклонять приглашения в пространства"</string>
<string name="screen_space_announcement_item3">"Откройте для себя все комнаты, к которым вы можете присоединиться в своих пространствах."</string> <string name="screen_space_announcement_item3">"Найти все комнаты, к которым можно присоединиться в ваших пространствах"</string>
<string name="screen_space_announcement_item4">"Присоединиться к публичному пространству"</string> <string name="screen_space_announcement_item4">"Присоединяться к публичным пространствам"</string>
<string name="screen_space_announcement_item5">"Покинуть все пространства, к которым вы присоединились"</string> <string name="screen_space_announcement_item5">"Покинуть все пространства, к которым вы присоединились"</string>
<string name="screen_space_announcement_notice">"Работа с пространствами скоро станет доступна"</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> <string name="screen_space_announcement_title">"Представляем пространства"</string>
</resources> </resources>

View file

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

View file

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

View file

@ -38,7 +38,7 @@ class CreateRoomFlowNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : BaseFlowNode<CreateRoomFlowNode.NavTarget>( ) : BaseFlowNode<CreateRoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.ConfigureRoom(isSpace = plugins.filterIsInstance<Inputs>().first().isSpace), initialElement = initialElementFromInputs(plugins.filterIsInstance<Inputs>().first()),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -46,7 +46,8 @@ class CreateRoomFlowNode(
) { ) {
@Parcelize @Parcelize
data class Inputs( data class Inputs(
val isSpace: Boolean val isSpace: Boolean,
val parentSpaceId: RoomId?,
) : NodeInputs, Parcelable ) : NodeInputs, Parcelable
private val callback: CreateRoomEntryPoint.Callback = callback() private val callback: CreateRoomEntryPoint.Callback = callback()
@ -54,7 +55,7 @@ class CreateRoomFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.ConfigureRoom -> { 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 { val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) { override fun onCreateRoomSuccess(roomId: RoomId) {
backstack.replace(NavTarget.AddPeople(roomId)) backstack.replace(NavTarget.AddPeople(roomId))
@ -81,9 +82,14 @@ class CreateRoomFlowNode(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data class ConfigureRoom(val isSpace: Boolean) : NavTarget data class ConfigureRoom(val isSpace: Boolean, val parentSpaceId: RoomId?) : NavTarget
@Parcelize @Parcelize
data class AddPeople(val roomId: RoomId) : NavTarget 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.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint { class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode( class Builder(
isSpace: Boolean, private val parentNode: Node,
parentNode: Node, private val buildContext: BuildContext,
buildContext: BuildContext, private val callback: CreateRoomEntryPoint.Callback,
callback: CreateRoomEntryPoint.Callback, ) : CreateRoomEntryPoint.Builder {
): Node { private var isSpace = false
val inputs = CreateRoomFlowNode.Inputs(isSpace) private var parentSpaceId: RoomId? = null
return parentNode.createNode<CreateRoomFlowNode>(buildContext, listOf(inputs, callback))
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 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 import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents { sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents data class JoinRuleChanged(val joinRuleItem: JoinRuleItem) : ConfigureRoomEvents
data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
data object CreateRoom : ConfigureRoomEvents data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data class SetParentSpace(val space: SpaceRoom?) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents data object CancelCreateRoom : ConfigureRoomEvents
} }

View file

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

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.net.toUri import androidx.core.net.toUri
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory 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.createroom.RoomPreset
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper 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.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.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.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEffect import io.element.android.libraries.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.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService 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.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber import timber.log.Timber
import kotlin.jvm.optionals.getOrDefault import kotlin.jvm.optionals.getOrDefault
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration.Companion.seconds
@AssistedInject @AssistedInject
class ConfigureRoomPresenter( class ConfigureRoomPresenter(
@Assisted private val isSpace: Boolean, @Assisted private val isSpace: Boolean,
@Assisted private val initialParentSpaceId: RoomId?,
private val dataStore: CreateRoomConfigStore, private val dataStore: CreateRoomConfigStore,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
@ -66,7 +76,7 @@ class ConfigureRoomPresenter(
) : Presenter<ConfigureRoomState> { ) : Presenter<ConfigureRoomState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(isSpace: Boolean): ConfigureRoomPresenter fun create(isSpace: Boolean, parentSpaceId: RoomId?): ConfigureRoomPresenter
} }
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@ -78,6 +88,7 @@ class ConfigureRoomPresenter(
@Composable @Composable
override fun present(): ConfigureRoomState { override fun present(): ConfigureRoomState {
val canAddRoomToSpace by featureFlagService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState() val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState()
val homeserverName = remember { matrixClient.userIdServerName() } 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) { LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false pendingPermissionRequest = false
@ -115,7 +138,7 @@ class ConfigureRoomPresenter(
RoomAddressValidityEffect( RoomAddressValidityEffect(
client = matrixClient, client = matrixClient,
roomAliasHelper = roomAliasHelper, roomAliasHelper = roomAliasHelper,
newRoomAddress = createRoomConfig.roomVisibility.roomAddress().getOrDefault(""), newRoomAddress = createRoomConfig.visibilityState.roomAddress().getOrDefault(""),
knownRoomAddress = null, knownRoomAddress = null,
) { newRoomAddressValidity -> ) { newRoomAddressValidity ->
roomAddressValidity.value = newRoomAddressValidity roomAddressValidity.value = newRoomAddressValidity
@ -124,6 +147,27 @@ class ConfigureRoomPresenter(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val createRoomAction: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } 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) { fun createRoom(config: CreateRoomConfig) {
createRoomAction.value = AsyncAction.Uninitialized createRoomAction.value = AsyncAction.Uninitialized
localCoroutineScope.createRoom(config, createRoomAction) localCoroutineScope.createRoom(config, createRoomAction)
@ -133,8 +177,7 @@ class ConfigureRoomPresenter(
when (event) { when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem) is ConfigureRoomEvents.JoinRuleChanged -> dataStore.setJoinRule(event.joinRuleItem)
is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress) is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig) is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvents.HandleAvatarAction -> { is ConfigureRoomEvents.HandleAvatarAction -> {
@ -149,19 +192,24 @@ class ConfigureRoomPresenter(
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
} }
} }
is ConfigureRoomEvents.SetParentSpace -> {
ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = AsyncAction.Uninitialized dataStore.setParentSpace(event.space)
}
ConfigureRoomEvents.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized
}
} }
} }
return ConfigureRoomState( return ConfigureRoomState(
isKnockFeatureEnabled = isKnockFeatureEnabled,
config = createRoomConfig, config = createRoomConfig,
avatarActions = avatarActions, avatarActions = avatarActions,
createRoomAction = createRoomAction.value, createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName, homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity.value, roomAddressValidity = roomAddressValidity.value,
availableJoinRules = availableJoinRules,
spaces = spaces,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@ -172,25 +220,27 @@ class ConfigureRoomPresenter(
) = launch { ) = launch {
suspend { suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) } 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( CreateRoomParameters(
name = config.roomName, name = config.roomName,
topic = config.topic, topic = config.topic,
isEncrypted = false, isEncrypted = false,
isDirect = false, isDirect = false,
visibility = RoomVisibility.Public, 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, preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId }, invite = config.invites.map { it.userId },
avatar = avatarUrl, avatar = avatarUrl,
roomAliasName = config.roomVisibility.roomAddress(), roomAliasName = config.visibilityState.roomAddress(),
isSpace = isSpace, isSpace = isSpace,
) )
} else { } else {
CreateRoomParameters( CreateRoomParameters(
name = config.roomName, name = config.roomName,
topic = config.topic, topic = config.topic,
isEncrypted = config.roomVisibility is RoomVisibilityState.Private, isEncrypted = config.visibilityState is RoomVisibilityState.Private,
isDirect = false, isDirect = false,
visibility = RoomVisibility.Private, visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited, historyVisibilityOverride = RoomHistoryVisibility.Invited,
@ -200,7 +250,7 @@ class ConfigureRoomPresenter(
isSpace = isSpace, isSpace = isSpace,
) )
} }
matrixClient.createRoom(params) val roomId = matrixClient.createRoom(params)
.onFailure { failure -> .onFailure { failure ->
Timber.e(failure, "Failed to create room") Timber.e(failure, "Failed to create room")
} }
@ -209,7 +259,22 @@ class ConfigureRoomPresenter(
analyticsService.capture(CreatedRoom(isDM = false)) analyticsService.capture(CreatedRoom(isDM = false))
} }
.getOrThrow() .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) }.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 { 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction 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.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState( data class ConfigureRoomState(
val isKnockFeatureEnabled: Boolean,
val config: CreateRoomConfig, val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: AsyncAction<RoomId>, val createRoomAction: AsyncAction<RoomId>,
val cameraPermissionState: PermissionsState, val cameraPermissionState: PermissionsState,
val roomAddressValidity: RoomAddressValidity, val roomAddressValidity: RoomAddressValidity,
val homeserverName: String, val homeserverName: String,
val availableJoinRules: ImmutableList<JoinRuleItem>,
val spaces: ImmutableList<SpaceRoom>,
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvents) -> Unit
) { ) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true && 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction 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.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.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.media.AvatarAction 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.RoomAddressValidity
import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> { open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
@ -28,9 +31,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(), invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
), ),
@ -39,9 +42,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(), invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
), ),
@ -49,9 +52,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig( config = CreateRoomConfig(
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
roomAddressValidity = RoomAddressValidity.NotAvailable, roomAddressValidity = RoomAddressValidity.NotAvailable,
@ -60,9 +63,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig( config = CreateRoomConfig(
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
roomAddressValidity = RoomAddressValidity.InvalidSymbols, roomAddressValidity = RoomAddressValidity.InvalidSymbols,
@ -71,9 +74,9 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig( config = CreateRoomConfig(
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room-101"), roomAddress = RoomAddress.AutoFilled("Room-101"),
roomAccess = RoomAccess.Knocking, joinRuleItem = JoinRuleItem.PublicVisibility.AskToJoin,
), ),
), ),
roomAddressValidity = RoomAddressValidity.Valid, roomAddressValidity = RoomAddressValidity.Valid,
@ -83,13 +86,41 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
isSpace = true, isSpace = true,
roomName = "Space 101", 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", 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"), roomAddress = RoomAddress.AutoFilled("Space-101"),
roomAccess = RoomAccess.Anyone, joinRuleItem = JoinRuleItem.PublicVisibility.Public,
), ),
), ),
roomAddressValidity = RoomAddressValidity.Valid, 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), cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
homeserverName: String = "matrix.org", homeserverName: String = "matrix.org",
roomAddressValidity: RoomAddressValidity = RoomAddressValidity.Valid, 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 = { }, eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState( ) = ConfigureRoomState(
config = config, config = config,
isKnockFeatureEnabled = isKnockFeatureEnabled,
avatarActions = avatarActions.toImmutableList(), avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction, createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName, homeserverName = homeserverName,
roomAddressValidity = roomAddressValidity, roomAddressValidity = roomAddressValidity,
availableJoinRules = availableVisibilityOptions.toImmutableList(),
spaces = spaces.toImmutableList(),
eventSink = eventSink, 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.atomic.atoms.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.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.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.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet 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.AvatarPickerState
import io.element.android.libraries.matrix.ui.components.AvatarPickerView import io.element.android.libraries.matrix.ui.components.AvatarPickerView
import io.element.android.libraries.matrix.ui.room.address.RoomAddressField import io.element.android.libraries.matrix.ui.room.address.RoomAddressField
import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
@Composable @Composable
@ -119,27 +121,30 @@ fun ConfigureRoomView(
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
) )
RoomVisibilityAndAccessOptions( if (!state.config.isSpace && state.spaces.isNotEmpty()) {
selected = when (state.config.roomVisibility) { SelectParentSpaceOptions(
is RoomVisibilityState.Private -> RoomVisibilityItem.Private spaces = state.spaces,
is RoomVisibilityState.Public -> when (state.config.roomVisibility.roomAccess) { selectedSpace = state.config.parentSpace,
RoomAccess.Knocking -> RoomVisibilityItem.AskToJoin onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
RoomAccess.Anyone -> RoomVisibilityItem.Public )
} }
},
isKnockingEnabled = state.isKnockFeatureEnabled, RoomJoinRuleOptions(
options = state.availableJoinRules,
selected = state.config.visibilityState.joinRuleItem,
parentSpace = state.config.parentSpace,
onOptionClick = { onOptionClick = {
focusManager.clearFocus() 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 { Column {
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title)) ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
RoomAddressField( RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.roomVisibility.roomAddress().getOrNull().orEmpty(), address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName, homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity, addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) }, onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
@ -264,7 +269,7 @@ private fun RoomTopic(
} }
@Composable @Composable
private fun ConfigureRoomOptions( internal fun ConfigureRoomOptions(
title: String, title: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
@ -278,30 +283,29 @@ private fun ConfigureRoomOptions(
} }
@Composable @Composable
private fun RoomVisibilityAndAccessOptions( private fun RoomJoinRuleOptions(
selected: RoomVisibilityItem, options: ImmutableList<JoinRuleItem>,
isKnockingEnabled: Boolean, selected: JoinRuleItem,
onOptionClick: (RoomVisibilityItem) -> Unit, onOptionClick: (JoinRuleItem) -> Unit,
parentSpace: SpaceRoom?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ConfigureRoomOptions( ConfigureRoomOptions(
title = stringResource(R.string.screen_create_room_room_access_section_title), title = stringResource(R.string.screen_create_room_room_access_section_title),
modifier = modifier, modifier = modifier,
) { ) {
RoomVisibilityItem.entries.forEach { item -> options.forEach { item ->
if (item == RoomVisibilityItem.AskToJoin && !isKnockingEnabled) {
return@forEach
}
val isSelected = item == selected val isSelected = item == selected
ListItem( ListItem(
leadingContent = ListItemContent.Custom { leadingContent = ListItemContent.Custom {
RoundedIconAtom( RoundedIconAtom(
size = RoundedIconAtomSize.Big, size = RoundedIconAtomSize.Big,
resourceId = when (item) { imageVector = when (item) {
RoomVisibilityItem.Public -> CompoundDrawables.ic_compound_public JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public()
RoomVisibilityItem.AskToJoin -> CompoundDrawables.ic_compound_user_add is JoinRuleItem.PublicVisibility.Restricted -> CompoundIcons.Space()
RoomVisibilityItem.Private -> CompoundDrawables.ic_compound_lock JoinRuleItem.PublicVisibility.AskToJoin,
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.Private -> CompoundIcons.Lock()
}, },
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary, tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
backgroundTint = Color.Transparent, backgroundTint = Color.Transparent,
@ -309,18 +313,29 @@ private fun RoomVisibilityAndAccessOptions(
}, },
headlineContent = { headlineContent = {
val title = when (item) { val title = when (item) {
RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_title) JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title) is JoinRuleItem.PublicVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_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) Text(text = title)
}, },
supportingContent = { supportingContent = {
// TODO handle description of items in a certain space/org
val description = when (item) { val description = when (item) {
RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_short_description) JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_description)
RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description) is JoinRuleItem.PublicVisibility.Restricted -> stringResource(
RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_description) 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) Text(text = description)
}, },

View file

@ -8,6 +8,7 @@
package io.element.android.features.createroom.impl.configureroom 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 io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -18,5 +19,6 @@ data class CreateRoomConfig(
val topic: String? = null, val topic: String? = null,
val avatarUri: String? = null, val avatarUri: String? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(), 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 dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.file.safeDelete 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.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
@ -33,23 +34,20 @@ class CreateRoomConfigStore(
fun setRoomName(roomName: String) { fun setRoomName(roomName: String) {
createRoomConfigFlow.getAndUpdate { config -> createRoomConfigFlow.getAndUpdate { config ->
val newVisibility = when (config.roomVisibility) { val roomAccessWithNewAddress = if (config.visibilityState is RoomVisibilityState.Public) {
is RoomVisibilityState.Public -> { val roomAddress = config.visibilityState.roomAddress
val roomAddress = config.roomVisibility.roomAddress if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) { val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName)
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(roomName) config.visibilityState.copy(roomAddress = RoomAddress.AutoFilled(roomAliasName))
config.roomVisibility.copy( } else {
roomAddress = RoomAddress.AutoFilled(roomAliasName), config.visibilityState
)
} else {
config.roomVisibility
}
} }
else -> config.roomVisibility } else {
config.visibilityState
} }
config.copy( config.copy(
roomName = roomName.takeIf { it.isNotEmpty() }, 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 -> createRoomConfigFlow.getAndUpdate { config ->
config.copy( config.copy(
roomVisibility = when (visibility) { visibilityState = when (joinRule) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private JoinRuleItem.Private -> RoomVisibilityState.Private()
RoomVisibilityItem.Public, RoomVisibilityItem.AskToJoin -> { is JoinRuleItem.PublicVisibility -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty()) val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public( RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasName), 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) { fun setRoomAddress(address: String) {
createRoomConfigFlow.getAndUpdate { config -> createRoomConfigFlow.getAndUpdate { config ->
config.copy( config.copy(
roomVisibility = when (config.roomVisibility) { visibilityState = when (config.visibilityState) {
is RoomVisibilityState.Public -> { is RoomVisibilityState.Public -> {
val sanitizedAddress = address.lowercase() val sanitizedAddress = address.lowercase()
config.roomVisibility.copy(roomAddress = RoomAddress.Edited(sanitizedAddress)) config.visibilityState.copy(roomAddress = RoomAddress.Edited(sanitizedAddress))
} }
else -> config.roomVisibility else -> config.visibilityState
}
)
}
}
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
} }
) )
} }
@ -120,6 +105,15 @@ class CreateRoomConfigStore(
} }
} }
fun setParentSpace(parentSpace: SpaceRoom?) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
parentSpace = parentSpace,
visibilityState = RoomVisibilityState.Private(),
)
}
}
fun clearCachedData() { fun clearCachedData() {
cachedAvatarUri = null 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 import java.util.Optional
sealed interface RoomVisibilityState { sealed interface RoomVisibilityState {
data object Private : RoomVisibilityState val joinRuleItem: JoinRuleItem
data class Private(override val joinRuleItem: JoinRuleItem.Private = JoinRuleItem.Private) : RoomVisibilityState
data class Public( data class Public(
val roomAddress: RoomAddress, val roomAddress: RoomAddress,
val roomAccess: RoomAccess, override val joinRuleItem: JoinRuleItem.PublicVisibility,
) : RoomVisibilityState ) : RoomVisibilityState
fun roomAddress(): Optional<String> { 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_action_create_room">"Nyt rum"</string>
<string name="screen_create_room_add_people_title">"Invitér andre"</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_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. <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> 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_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_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_access_section_public_option_title">"Offentlig"</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_access_section_restricted_option_description">"Alle i %1$s kan deltage."</string>
<string name="screen_create_room_room_address_section_title">"Rummets adresse"</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_room_visibility_section_title">"Rummets synlighed"</string>
<string name="screen_create_room_topic_label">"Emne (valgfrit)"</string> <string name="screen_create_room_topic_label">"Emne (valgfrit)"</string>
<string name="screen_create_room_topic_placeholder">"Tilføj beskrivelse…"</string>
</resources> </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_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_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_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_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_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_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_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_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_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_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">" Sichtbarkeit des Chats"</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_label">"Thema (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Beschreibung hinzufügen…"</string> <string name="screen_create_room_topic_placeholder">"Beschreibung hinzufügen…"</string>
</resources> </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_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_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_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_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_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_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_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_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_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_address_section_title">"Adresse"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilité du salon"</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_label">"Sujet (facultatif)"</string>
<string name="screen_create_room_topic_placeholder">"Ajouter une description…"</string> <string name="screen_create_room_topic_placeholder">"Ajouter une description…"</string>
</resources> </resources>

View file

@ -3,16 +3,33 @@
<string name="screen_create_room_action_create_room">"Új szoba"</string> <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_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_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. <string name="screen_create_room_public_option_description">"Bárki megtalálhatja ezt a szobát.
Ezt bármikor módosíthatja a szobabeállításokban."</string> Ezt bármikor módosíthatja a szobabeállításokban."</string>
<string name="screen_create_room_public_option_short_description">"Bárki csatlakozhat."</string>
<string name="screen_create_room_public_option_title">"Nyilvános szoba"</string> <string name="screen_create_room_public_option_title">"Nyilvános szoba"</string>
<string name="screen_create_room_room_access_section_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_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_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_public_option_title">"Bárki"</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_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_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_label">"Téma (nem kötelező)"</string>
<string name="screen_create_room_topic_placeholder">"Leírás hozzáadása…"</string>
</resources> </resources>

View file

@ -3,16 +3,17 @@
<string name="screen_create_room_action_create_room">"Nytt rom"</string> <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_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_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. <string name="screen_create_room_public_option_description">"Alle kan finne dette rommet.
Du kan endre dette når som helst i rominnstillingene."</string> 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_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_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_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_footer">"Du trenger en adresse for å gjøre den synlig i den offentlige katalogen."</string>
<string name="screen_create_room_room_address_section_title">"Romadresse"</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_room_visibility_section_title">"Romsynlighet"</string>
<string name="screen_create_room_topic_label">"Emne (valgfritt)"</string> <string name="screen_create_room_topic_label">"Emne (valgfritt)"</string>
</resources> </resources>

View file

@ -4,6 +4,8 @@
<string name="screen_create_room_add_people_title">"Convidar pessoas"</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_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_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_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_description">"Apenas pessoas convidadas podem entrar."</string>
<string name="screen_create_room_private_option_title">"Privada"</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_short_description">"Qualquer um pode entrar."</string>
<string name="screen_create_room_public_option_title">"Publica"</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_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_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_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_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_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 da sala"</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_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_label">"Tópico (opcional)"</string>
<string name="screen_create_room_topic_placeholder">"Adicione a descrição…"</string>
</resources> </resources>

View file

@ -3,16 +3,17 @@
<string name="screen_create_room_action_create_room">"Nova sala"</string> <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_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_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. <string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala.
Pode alterar esta opção nas definições da sala."</string> Pode alterar esta opção nas definições da sala."</string>
<string name="screen_create_room_public_option_title">"Sala pública"</string> <string name="screen_create_room_public_option_title">"Sala pública"</string>
<string name="screen_create_room_room_access_section_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_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_description">"Qualquer pessoa 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_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 de salas, precisas de um endereço de sala."</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 da sala"</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_room_visibility_section_title">"Visibilidade da sala"</string>
<string name="screen_create_room_topic_label">"Descrição (opcional)"</string> <string name="screen_create_room_topic_label">"Descrição (opcional)"</string>
</resources> </resources>

View file

@ -3,16 +3,26 @@
<string name="screen_create_room_action_create_room">"Создать новую комнату"</string> <string name="screen_create_room_action_create_room">"Создать новую комнату"</string>
<string name="screen_create_room_add_people_title">"Пригласить в комнату"</string> <string name="screen_create_room_add_people_title">"Пригласить в комнату"</string>
<string name="screen_create_room_error_creating_room">"Произошла ошибка при создании комнаты"</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 name="screen_create_room_public_option_description">"Любой желающий может найти эту комнату.
Вы можете изменить это в любое время в настройках комнаты."</string> Вы можете изменить это в любое время в настройках комнаты."</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_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_description">"Любой желающий может подать заявку на присоединение к комнате, но администратор или модератор должен будет принять запрос."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Попросить присоединиться"</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_private_option_description">"Присоединиться могут только приглашенные."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Любой"</string> <string name="screen_create_room_room_access_section_private_option_title">"Частный"</string>
<string name="screen_create_room_room_address_section_footer">"Чтобы эта комната была видна в каталоге общедоступных, вам необходим ее адрес"</string> <string name="screen_create_room_room_access_section_public_option_description">"Присоединиться может любой желающий."</string>
<string name="screen_create_room_room_address_section_title">"Адрес комнаты"</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_room_visibility_section_title">"Видимость комнаты"</string>
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string> <string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
<string name="screen_create_room_topic_placeholder">"Добавить описание…"</string>
</resources> </resources>

View file

@ -3,16 +3,33 @@
<string name="screen_create_room_action_create_room">"Nová miestnosť"</string> <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_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_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. <string name="screen_create_room_public_option_description">"Túto miestnosť môže nájsť ktokoľvek.
Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."</string> Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."</string>
<string name="screen_create_room_public_option_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_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_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">"Požiadať o pripojenie"</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_public_option_description">"Do tejto miestnosti sa môže pripojiť ktokoľvek"</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_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_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_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_label">"Téma (voliteľné)"</string>
<string name="screen_create_room_topic_placeholder">"Pridať popis…"</string>
</resources> </resources>

View file

@ -7,6 +7,8 @@
<string name="screen_create_room_name_placeholder">"Add name…"</string> <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_room_title">"New room"</string>
<string name="screen_create_room_new_space_title">"New space"</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_description">"Only people invited can join."</string>
<string name="screen_create_room_private_option_title">"Private"</string> <string name="screen_create_room_private_option_title">"Private"</string>
<string name="screen_create_room_public_option_description">"Anyone can find this room. <string name="screen_create_room_public_option_description">"Anyone can find this room.
@ -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_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_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_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_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_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_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_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_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_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_address_section_title">"Address"</string>
<string name="screen_create_room_room_visibility_section_title">"Room visibility"</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_label">"Topic (optional)"</string>
<string name="screen_create_room_topic_placeholder">"Add description…"</string> <string name="screen_create_room_topic_placeholder">"Add description…"</string>
</resources> </resources>

View file

@ -1,12 +1,11 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * 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 android.net.Uri
import androidx.core.net.toUri 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.ConfigureRoomState
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig 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.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.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias 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.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_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION 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_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID 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.A_ROOM_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.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.media.AvatarAction
import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidity
import io.element.android.libraries.mediapickers.api.PickerProvider 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.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -81,7 +89,7 @@ class ConfigureRoomPresenterTest {
assertThat(initialState.config.topic).isNull() assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty() assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull() assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private) assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private())
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org") assertThat(initialState.homeserverName).isEqualTo("matrix.org")
} }
@ -174,12 +182,12 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy // Room privacy
newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) newState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy( expectedConfig = expectedConfig.copy(
roomVisibility = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")), roomAddress = RoomAddress.AutoFilled(roomAliasHelper.roomAliasNameFromRoomDisplayName(expectedConfig.roomName ?: "")),
roomAccess = RoomAccess.Anyone, joinRuleItem = JoinRuleItem.PublicVisibility.Public,
) )
) )
assertThat(newState.config).isEqualTo(expectedConfig) 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 @Test
fun `present - record analytics when creating room`() = runTest { fun `present - record analytics when creating room`() = runTest {
val matrixClient = createMatrixClient() val matrixClient = createMatrixClient()
@ -311,7 +422,7 @@ class ConfigureRoomPresenterTest {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address")) initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address"))
skipItems(1) skipItems(1)
@ -331,7 +442,7 @@ class ConfigureRoomPresenterTest {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address")) initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1) skipItems(1)
@ -351,7 +462,7 @@ class ConfigureRoomPresenterTest {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public)) initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address")) initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address"))
skipItems(1) 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 { private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1) skipItems(1)
return awaitItem() return awaitItem()
} }
private fun createMatrixClient(isAliasAvailable: Boolean = true) = FakeMatrixClient( private fun createMatrixClient(
isAliasAvailable: Boolean = true,
spaceService: FakeSpaceService = FakeSpaceService(),
) = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" }, userIdServerNameLambda = { "matrix.org" },
resolveRoomAliasResult = { resolveRoomAliasResult = {
val resolvedRoomAlias = if (isAliasAvailable) { val resolvedRoomAlias = if (isAliasAvailable) {
@ -376,11 +526,13 @@ class ConfigureRoomPresenterTest {
Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList())) Optional.of(ResolvedRoomAlias(A_ROOM_ID, emptyList()))
} }
Result.success(resolvedRoomAlias) Result.success(resolvedRoomAlias)
} },
spaceService = spaceService,
) )
private fun createConfigureRoomPresenter( private fun createConfigureRoomPresenter(
isSpace: Boolean = false, isSpace: Boolean = false,
initialParenSpaceId: RoomId? = null,
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(), roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper), dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper),
matrixClient: MatrixClient = createMatrixClient(), matrixClient: MatrixClient = createMatrixClient(),
@ -392,6 +544,7 @@ class ConfigureRoomPresenterTest {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = ConfigureRoomPresenter( ) = ConfigureRoomPresenter(
isSpace = isSpace, isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId,
dataStore = dataStore, dataStore = dataStore,
matrixClient = matrixClient, matrixClient = matrixClient,
mediaPickerProvider = pickerProvider, mediaPickerProvider = pickerProvider,

View file

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

View file

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

View file

@ -33,5 +33,5 @@ data class HomeState(
) { ) {
val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters 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, lazyListState = spacesLazyListState,
onSpaceClick = { spaceId -> onSpaceClick = { spaceId ->
onRoomClick(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 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.datasource.RoomListRoomSummaryFactory
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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 io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
@ -25,16 +28,23 @@ import kotlinx.coroutines.flow.map
private const val PAGE_SIZE = 30 private const val PAGE_SIZE = 30
@Inject @AssistedInject
class RoomListSearchDataSource( class RoomListSearchDataSource(
@Assisted coroutineScope: CoroutineScope,
roomListService: RoomListService, roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers, coroutineDispatchers: CoroutineDispatchers,
private val roomSummaryFactory: RoomListRoomSummaryFactory, private val roomSummaryFactory: RoomListRoomSummaryFactory,
) { ) {
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource
}
private val roomList = roomListService.createRoomList( private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE, pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.None, initialFilter = RoomListFilter.None,
source = RoomList.Source.All, source = RoomList.Source.All,
coroutineScope = coroutineScope
) )
val roomSummaries: Flow<ImmutableList<RoomListRoomSummary>> = roomList.filteredSummaries 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -23,7 +24,7 @@ import kotlinx.collections.immutable.persistentListOf
@Inject @Inject
class RoomListSearchPresenter( class RoomListSearchPresenter(
private val dataSource: RoomListSearchDataSource, private val dataSourceFactory: RoomListSearchDataSource.Factory,
) : Presenter<RoomListSearchState> { ) : Presenter<RoomListSearchState> {
@Composable @Composable
override fun present(): RoomListSearchState { override fun present(): RoomListSearchState {
@ -33,6 +34,9 @@ class RoomListSearchPresenter(
} }
val searchQuery = rememberTextFieldState() val searchQuery = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { dataSourceFactory.create(coroutineScope) }
LaunchedEffect(isSearchActive) { LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive) dataSource.setIsActive(isSearchActive)
} }

View file

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

View file

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

View file

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

View file

@ -8,23 +8,40 @@
package io.element.android.features.home.impl.spaces 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.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider 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.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership 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.SpaceHeaderRootView
import io.element.android.libraries.matrix.ui.components.SpaceHeaderView 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.components.SpaceRoomItemView
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
@ -32,56 +49,119 @@ fun HomeSpacesView(
state: HomeSpacesState, state: HomeSpacesState,
lazyListState: LazyListState, lazyListState: LazyListState,
onSpaceClick: (RoomId) -> Unit, onSpaceClick: (RoomId) -> Unit,
onCreateSpaceClick: () -> Unit,
onExploreClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( if (state.canCreateSpaces && state.spaceRooms.isEmpty()) {
modifier = modifier, EmptySpaceHomeView(
state = lazyListState modifier = modifier,
) { onCreateSpaceClick = onCreateSpaceClick,
val space = state.space onExploreClick = onExploreClick,
when (space) { canExploreSpaces = state.canExploreSpaces,
CurrentSpace.Root -> { )
item { } else {
SpaceHeaderRootView(numberOfSpaces = state.spaceRooms.size) 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( item {
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) {
HorizontalDivider() 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 @PreviewsDayNight
@Composable @Composable
internal fun HomeSpacesViewPreview( internal fun HomeSpacesViewPreview(
@ -91,6 +171,7 @@ internal fun HomeSpacesViewPreview(
state = state, state = state,
lazyListState = rememberLazyListState(), lazyListState = rememberLazyListState(),
onSpaceClick = {}, 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_read">"Marker som læst"</string>
<string name="screen_roomlist_mark_as_unread">"Marker som ulæ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_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_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> <string name="session_verification_banner_title">"Bekræft, at det er dig"</string>
</resources> </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_read">"Als gelesen markieren"</string>
<string name="screen_roomlist_mark_as_unread">"Als ungelesen 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_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_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> <string name="session_verification_banner_title">"Verifiziere deine Identität"</string>
</resources> </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_read">"Marquer comme lu"</string>
<string name="screen_roomlist_mark_as_unread">"Marquer comme non 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_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_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> <string name="session_verification_banner_title">"Vérifier que cest bien vous"</string>
</resources> </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_read">"Megjelölés olvasottként"</string>
<string name="screen_roomlist_mark_as_unread">"Megjelölés olvasatlanké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_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_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> <string name="session_verification_banner_title">"Ellenőrizze, hogy Ön az"</string>
</resources> </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_read">"Marcar como lida"</string>
<string name="screen_roomlist_mark_as_unread">"Marcar como não 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_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_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> <string name="session_verification_banner_title">"Verifique se é você"</string>
</resources> </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_read">"Označiť ako prečítané"</string>
<string name="screen_roomlist_mark_as_unread">"Označiť ako neprečí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_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_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> <string name="session_verification_banner_title">"Overte, že ste to vy"</string>
</resources> </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_read">"Mark as read"</string>
<string name="screen_roomlist_mark_as_unread">"Mark as unread"</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_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_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> <string name="session_verification_banner_title">"Verify its you"</string>
</resources> </resources>

View file

@ -173,7 +173,7 @@ class HomePresenterTest {
} }
@Test @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 homeSpacesPresenter = MutablePresenter(aHomeSpacesState())
val presenter = createHomePresenter( val presenter = createHomePresenter(
sessionStore = InMemorySessionStore( sessionStore = InMemorySessionStore(
@ -193,7 +193,7 @@ class HomePresenterTest {
val spaceState = awaitItem() val spaceState = awaitItem()
assertThat(spaceState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) assertThat(spaceState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces)
// The last space is left // The last space is left
homeSpacesPresenter.updateState(aHomeSpacesState(spaceRooms = emptyList())) homeSpacesPresenter.updateState(aHomeSpacesState(spaceRooms = emptyList(), canCreateSpaces = false))
skipItems(1) skipItems(1)
val finalState = awaitItem() val finalState = awaitItem()
// We are back to Chats // 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.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -122,13 +123,18 @@ fun TestScope.createRoomListSearchPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
): RoomListSearchPresenter { ): RoomListSearchPresenter {
return RoomListSearchPresenter( return RoomListSearchPresenter(
dataSource = RoomListSearchDataSource( dataSourceFactory = object : RoomListSearchDataSource.Factory {
roomListService = roomListService, override fun create(coroutineScope: CoroutineScope): RoomListSearchDataSource {
roomSummaryFactory = aRoomListRoomSummaryFactory( return RoomListSearchDataSource(
dateFormatter = FakeDateFormatter(), roomListService = roomListService,
roomLatestEventFormatter = FakeRoomLatestEventFormatter(), roomSummaryFactory = aRoomListRoomSummaryFactory(
), dateFormatter = FakeDateFormatter(),
coroutineDispatchers = testCoroutineDispatchers(), 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 { sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents {
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
data class UpdateSearchQuery(val query: String) : DefaultInvitePeopleEvents
data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents
} }

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.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.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -82,9 +85,13 @@ private fun InvitePeopleContentView(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
fun toggleUser(user: MatrixUser) {
state.eventSink(DefaultInvitePeopleEvents.ToggleUser(user))
}
InvitePeopleSearchBar( InvitePeopleSearchBar(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
query = state.searchQuery, queryState = state.searchQuery,
showLoader = state.showSearchLoader, showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers, selectedUsers = state.selectedUsers,
state = state.searchResults, state = state.searchResults,
@ -96,18 +103,45 @@ private fun InvitePeopleContentView(
) )
) )
}, },
onTextChange = { state.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery(it)) }, onToggleUser = ::toggleUser,
onToggleUser = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) },
) )
if (!state.isSearchActive) { if (!state.isSearchActive) {
SelectedUsersRowList( if (state.selectedUsers.isNotEmpty()) {
modifier = Modifier.fillMaxWidth(), SelectedUsersRowList(
selectedUsers = state.selectedUsers, modifier = Modifier.fillMaxWidth(),
autoScroll = true, selectedUsers = state.selectedUsers,
onUserRemove = { state.eventSink(DefaultInvitePeopleEvents.ToggleUser(it)) }, autoScroll = true,
contentPadding = PaddingValues(16.dp), 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun InvitePeopleSearchBar( private fun InvitePeopleSearchBar(
query: String, queryState: TextFieldState,
state: SearchBarResultState<ImmutableList<InvitableUser>>, state: SearchBarResultState<ImmutableList<InvitableUser>>,
showLoader: Boolean, showLoader: Boolean,
selectedUsers: ImmutableList<MatrixUser>, selectedUsers: ImmutableList<MatrixUser>,
active: Boolean, active: Boolean,
onActiveChange: (Boolean) -> Unit, onActiveChange: (Boolean) -> Unit,
onTextChange: (String) -> Unit,
onToggleUser: (MatrixUser) -> Unit, onToggleUser: (MatrixUser) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone),
) { ) {
SearchBar( SearchBar(
query = query, queryState = queryState,
onQueryChange = onTextChange,
active = active, active = active,
onActiveChange = onActiveChange, onActiveChange = onActiveChange,
modifier = modifier, modifier = modifier,
@ -140,7 +172,7 @@ private fun InvitePeopleSearchBar(
selectedUsers = selectedUsers, selectedUsers = selectedUsers,
autoScroll = true, autoScroll = true,
onUserRemove = onToggleUser, onUserRemove = onToggleUser,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(all = 16.dp),
) )
} }
}, },

View file

@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.invitepeople.api.InvitePeopleEvents 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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState 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
import io.element.android.libraries.matrix.test.A_USER_ID_2 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.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.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.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomMemberList import io.element.android.libraries.matrix.test.room.aRoomMemberList
import io.element.android.libraries.matrix.ui.components.aMatrixUser 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.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.canInvite).isFalse() assertThat(initialState.canInvite).isFalse()
assertThat(initialState.searchQuery).isEmpty() assertThat(initialState.searchQuery.text.toString()).isEmpty()
skipItems(1) cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - updates search active state`() = runTest { fun `present - updates search active state`() = runTest {
val presenter = createDefaultInvitePeoplePresenter() val presenter = createDefaultInvitePeoplePresenter(
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1) skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.OnSearchActiveChanged(true)) initialState.eventSink(DefaultInvitePeopleEvents.OnSearchActiveChanged(true))
val resultState = awaitItem() val resultState = awaitItemAsDefault()
assertThat(resultState.isSearchActive).isTrue() assertThat(resultState.isSearchActive).isTrue()
resultState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(awaitItemAsDefault().searchQuery).isEqualTo("some query") assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
resultState.eventSink(InvitePeopleEvents.CloseSearch) resultState.eventSink(InvitePeopleEvents.CloseSearch)
skipItems(1) skipItems(2)
awaitItemAsDefault().also { awaitItemAsDefault().also {
assertThat(it.isSearchActive).isFalse() 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) coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItemAsDefault()
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true)) repository.emitState(UserSearchResultState(results = emptyList(), isSearching = true))
skipItems(3) skipItems(3)
@ -126,10 +133,10 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItemAsDefault()
skipItems(1) skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
@ -179,10 +186,10 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItemAsDefault()
skipItems(1) skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
@ -239,10 +246,10 @@ internal class DefaultInvitePeoplePresenterTest {
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItemAsDefault()
skipItems(1) skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
@ -275,7 +282,7 @@ internal class DefaultInvitePeoplePresenterTest {
val repository = FakeUserRepository() val repository = FakeUserRepository()
val presenter = createDefaultInvitePeoplePresenter( val presenter = createDefaultInvitePeoplePresenter(
userRepository = repository, userRepository = repository,
coroutineDispatchers = testCoroutineDispatchers() coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
@ -306,14 +313,14 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItemAsDefault()
skipItems(1) skipItems(1)
val selectedUser = aMatrixUser() val selectedUser = aMatrixUser()
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser))
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
@ -344,13 +351,13 @@ internal class DefaultInvitePeoplePresenterTest {
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
) )
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItemAsDefault()
skipItems(1) skipItems(1)
val selectedUser = aMatrixUser() val selectedUser = aMatrixUser()
// Given a query is made // Given a query is made
initialState.eventSink(DefaultInvitePeopleEvents.UpdateSearchQuery("some query")) initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") 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( private suspend fun FakeUserRepository.emitStateWithUsers(
users: List<MatrixUser>, users: List<MatrixUser>,
isSearching: Boolean = false isSearching: Boolean = false

View file

@ -1,16 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_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_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_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_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_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_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">"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_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_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> <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_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_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_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_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_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. <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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_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_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_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_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_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_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">"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_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_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> <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_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_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_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_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_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. <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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_step3">"Отсканируйте QR-код с помощью этого устройства"</string>
<string name="screen_link_new_device_desktop_submit">"Готово к сканированию"</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_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_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_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_subtitle">"Вход на другом устройстве был отменен."</string>
<string name="screen_link_new_device_error_request_cancelled_title">"Запрос на вход отменен"</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_subtitle">"Срок действия входа истек. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_link_new_device_error_request_timeout_title">"Вход в систему не был выполнен вовремя"</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">"Выберите %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_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_header">"Что теперь?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Попробуйте снова войти в систему с помощью QR-кода, если это была проблема с соединением"</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_cancelled_title">"Запрос на вход отменен"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Вход в систему был отклонен на другом устройстве."</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_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_subtitle">"Срок действия входа истек. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_qr_code_login_error_expired_title">"Вход в систему не был выполнен вовремя"</string> <string name="screen_qr_code_login_error_expired_title">"Вход в систему не был выполнен вовремя"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Другое устройство не поддерживает вход в %s с помощью QR-кода. <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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_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_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_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_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_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_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">"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_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_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> <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_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_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_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_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_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. <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.permissions.test)
testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.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_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_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_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_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_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. <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_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_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_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_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_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. <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_cancelled_title">"Запрос на вход отменен"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Вход в систему был отклонен на другом устройстве."</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_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_subtitle">"Срок действия входа истек. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_qr_code_login_error_expired_title">"Вход в систему не был выполнен вовремя"</string> <string name="screen_qr_code_login_error_expired_title">"Вход в систему не был выполнен вовремя"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Другое устройство не поддерживает вход в %s с помощью QR-кода. <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_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_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_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_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_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. <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 package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData 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.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.After
import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -30,6 +34,19 @@ class QrCodeScanViewTest {
@get:Rule @get:Rule
val rule = createAndroidComposeRule<ComponentActivity>() 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 @Test
fun `on back pressed - calls the expected callback`() { fun `on back pressed - calls the expected callback`() {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->

View file

@ -68,7 +68,6 @@ dependencies {
implementation(libs.jsoup) implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j) 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.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface MessagesEvents { sealed interface MessagesEvent {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvent
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvents data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvents data class OnUserClicked(val user: MatrixUser) : MessagesEvent
data object Dismiss : MessagesEvents data object MarkAsFullyReadAndExit : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvents
} }
enum class InviteDialogAction { 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.MessageComposerEvent
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter 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.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.TimelinePresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
@ -151,7 +151,7 @@ class MessagesNode(
activity: Activity, activity: Activity,
darkTheme: Boolean, darkTheme: Boolean,
url: String, url: String,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvent) -> Unit,
customTab: Boolean customTab: Boolean
) { ) {
when (val permalink = permalinkParser.parse(url)) { when (val permalink = permalinkParser.parse(url)) {
@ -178,12 +178,12 @@ class MessagesNode(
private fun handleRoomLinkClick( private fun handleRoomLinkClick(
roomLink: PermalinkData.RoomLink, roomLink: PermalinkData.RoomLink,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvent) -> Unit,
) { ) {
if (room.matches(roomLink.roomIdOrAlias)) { if (room.matches(roomLink.roomIdOrAlias)) {
val eventId = roomLink.eventId val eventId = roomLink.eventId
if (eventId != null) { if (eventId != null) {
eventSink(TimelineEvents.FocusOnEvent(eventId)) eventSink(TimelineEvent.FocusOnEvent(eventId))
} else { } else {
// Click on the same room, ignore // Click on the same room, ignore
displaySameRoomToast() displaySameRoomToast()
@ -242,7 +242,7 @@ class MessagesNode(
val state = presenter.present() val state = presenter.present()
BackHandler { BackHandler {
state.eventSink(MessagesEvents.MarkAsFullyReadAndExit) state.eventSink(MessagesEvent.MarkAsFullyReadAndExit)
} }
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
@ -253,7 +253,7 @@ class MessagesNode(
} }
MessagesView( MessagesView(
state = state, state = state,
onBackClick = { state.eventSink(MessagesEvents.MarkAsFullyReadAndExit) }, onBackClick = { state.eventSink(MessagesEvent.MarkAsFullyReadAndExit) },
onRoomDetailsClick = callback::navigateToRoomDetails, onRoomDetailsClick = callback::navigateToRoomDetails,
onEventContentClick = { isLive, event -> onEventContentClick = { isLive, event ->
if (isLive) { if (isLive) {
@ -305,7 +305,7 @@ class MessagesNode(
} }
LaunchedEffect(focusedEventId) { LaunchedEffect(focusedEventId) {
if (focusedEventId != null) { if (focusedEventId != null) {
state.timelineState.eventSink(TimelineEvents.FocusOnEvent(focusedEventId!!)) state.timelineState.eventSink(TimelineEvent.FocusOnEvent(focusedEventId!!))
focusedEventId = null focusedEventId = null
} }
} }

View file

@ -27,10 +27,8 @@ import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig import io.element.android.appconfig.MessageComposerConfig
import io.element.android.features.messages.api.timeline.HtmlConverterProvider 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.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction 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.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent 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.pinned.banner.PinnedMessagesBannerState
import io.element.android.features.messages.impl.timeline.MarkAsFullyRead 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.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.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo 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.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.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -102,7 +101,6 @@ class MessagesPresenter(
@Assisted private val timelinePresenter: Presenter<TimelineState>, @Assisted private val timelinePresenter: Presenter<TimelineState>,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>, private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val identityChangeStatePresenter: Presenter<IdentityChangeState>, private val identityChangeStatePresenter: Presenter<IdentityChangeState>,
private val historyVisibleStatePresenter: Presenter<HistoryVisibleState>,
private val linkPresenter: Presenter<LinkState>, private val linkPresenter: Presenter<LinkState>,
@Assisted private val actionListPresenter: Presenter<ActionListState>, @Assisted private val actionListPresenter: Presenter<ActionListState>,
private val customReactionPresenter: Presenter<CustomReactionState>, private val customReactionPresenter: Presenter<CustomReactionState>,
@ -154,7 +152,6 @@ class MessagesPresenter(
val timelineState = timelinePresenter.present() val timelineState = timelinePresenter.present()
val timelineProtectionState = timelineProtectionPresenter.present() val timelineProtectionState = timelineProtectionPresenter.present()
val identityChangeState = identityChangeStatePresenter.present() val identityChangeState = identityChangeStatePresenter.present()
val historyVisibleState = historyVisibleStatePresenter.present()
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
val linkState = linkPresenter.present() val linkState = linkPresenter.present()
val customReactionState = customReactionPresenter.present() val customReactionState = customReactionPresenter.present()
@ -208,6 +205,16 @@ class MessagesPresenter(
val dmRoomMember by room.getDirectRoomMember(membersState) val dmRoomMember by room.getDirectRoomMember(membersState)
val roomMemberIdentityStateChanges = identityChangeState.roomMemberIdentityStateChanges 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) { LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) {
if (roomInfo.isEncrypted == true) { if (roomInfo.isEncrypted == true) {
val dmRoomMemberId = dmRoomMember?.userId val dmRoomMemberId = dmRoomMember?.userId
@ -221,9 +228,9 @@ class MessagesPresenter(
onPauseOrDispose {} onPauseOrDispose {}
} }
fun handleEvent(event: MessagesEvents) { fun handleEvent(event: MessagesEvent) {
when (event) { when (event) {
is MessagesEvents.HandleAction -> { is MessagesEvent.HandleAction -> {
localCoroutineScope.handleTimelineAction( localCoroutineScope.handleTimelineAction(
action = event.action, action = event.action,
targetEvent = event.event, targetEvent = event.event,
@ -233,21 +240,20 @@ class MessagesPresenter(
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
) )
} }
is MessagesEvents.ToggleReaction -> { is MessagesEvent.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId) localCoroutineScope.toggleReaction(event.emoji, event.eventOrTransactionId)
} }
is MessagesEvents.InviteDialogDismissed -> { is MessagesEvent.InviteDialogDismissed -> {
hasDismissedInviteDialog = true hasDismissedInviteDialog = true
if (event.action == InviteDialogAction.Invite) { if (event.action == InviteDialogAction.Invite) {
localCoroutineScope.reinviteOtherUser(inviteProgress) localCoroutineScope.reinviteOtherUser(inviteProgress)
} }
} }
is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) is MessagesEvent.OnUserClicked -> {
is MessagesEvents.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user)) roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
} }
is MessagesEvents.MarkAsFullyReadAndExit -> coroutineScope.launch { is MessagesEvent.MarkAsFullyReadAndExit -> coroutineScope.launch {
if (!markingAsReadAndExiting.getAndSet(true)) { if (!markingAsReadAndExiting.getAndSet(true)) {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
Timber.w(it, "Failed to get latest event id to mark as fully read") Timber.w(it, "Failed to get latest event id to mark as fully read")
@ -277,7 +283,6 @@ class MessagesPresenter(
timelineState = timelineState, timelineState = timelineState,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState, identityChangeState = identityChangeState,
historyVisibleState = historyVisibleState,
linkState = linkState, linkState = linkState,
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState, customReactionState = customReactionState,
@ -292,6 +297,7 @@ class MessagesPresenter(
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState, dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState, roomMemberModerationState = roomMemberModerationState,
showSharedHistoryIcon = showSharedHistoryIcon,
successorRoom = roomInfo.successorRoom, successorRoom = roomInfo.successorRoom,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
@ -529,7 +535,7 @@ class MessagesPresenter(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineState: TimelineState, 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) { 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.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.actionlist.ActionListState 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.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
@ -41,7 +40,6 @@ data class MessagesState(
val timelineState: TimelineState, val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState, val timelineProtectionState: TimelineProtectionState,
val identityChangeState: IdentityChangeState, val identityChangeState: IdentityChangeState,
val historyVisibleState: HistoryVisibleState,
val linkState: LinkState, val linkState: LinkState,
val actionListState: ActionListState, val actionListState: ActionListState,
val customReactionState: CustomReactionState, val customReactionState: CustomReactionState,
@ -56,8 +54,10 @@ data class MessagesState(
val pinnedMessagesBannerState: PinnedMessagesBannerState, val pinnedMessagesBannerState: PinnedMessagesBannerState,
val dmUserVerificationState: IdentityState?, val dmUserVerificationState: IdentityState?,
val roomMemberModerationState: RoomMemberModerationState, val roomMemberModerationState: RoomMemberModerationState,
/** Should the top bar include the "history" icon? */
val showSharedHistoryIcon: Boolean,
val successorRoom: SuccessorRoom?, val successorRoom: SuccessorRoom?,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvent) -> Unit
) { ) {
val isTombstoned = successorRoom != null 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.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.messages.impl.actionlist.ActionListState 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.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.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.aRoomMemberIdentityStateChange import io.element.android.features.messages.impl.crypto.identity.aRoomMemberIdentityStateChange
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState 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.TimelineState
import io.element.android.features.messages.impl.timeline.aTimelineItemList 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.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.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.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.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
@ -92,15 +90,6 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())) 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(), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
identityChangeState: IdentityChangeState = anIdentityChangeState(), identityChangeState: IdentityChangeState = anIdentityChangeState(),
historyVisibleState: HistoryVisibleState = aHistoryVisibleState(),
linkState: LinkState = aLinkState(), linkState: LinkState = aLinkState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(), actionListState: ActionListState = anActionListState(),
@ -132,8 +120,9 @@ fun aMessagesState(
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(), pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
dmUserVerificationState: IdentityState? = null, dmUserVerificationState: IdentityState? = null,
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(), roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
showSharedHistoryIcon: Boolean = false,
successorRoom: SuccessorRoom? = null, successorRoom: SuccessorRoom? = null,
eventSink: (MessagesEvents) -> Unit = {}, eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState( ) = MessagesState(
roomId = RoomId("!id:domain"), roomId = RoomId("!id:domain"),
roomName = roomName, roomName = roomName,
@ -144,7 +133,6 @@ fun aMessagesState(
voiceMessageComposerState = voiceMessageComposerState, voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState, identityChangeState = identityChangeState,
historyVisibleState = historyVisibleState,
linkState = linkState, linkState = linkState,
timelineState = timelineState, timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState, readReceiptBottomSheetState = readReceiptBottomSheetState,
@ -160,6 +148,7 @@ fun aMessagesState(
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
dmUserVerificationState = dmUserVerificationState, dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState, roomMemberModerationState = roomMemberModerationState,
showSharedHistoryIcon = showSharedHistoryIcon,
successorRoom = successorRoom, successorRoom = successorRoom,
eventSink = eventSink, eventSink = eventSink,
) )
@ -187,7 +176,7 @@ fun aUserEventPermissions(
fun aReactionSummaryState( fun aReactionSummaryState(
target: ReactionSummaryState.Summary? = null, target: ReactionSummaryState.Summary? = null,
eventSink: (ReactionSummaryEvents) -> Unit = {} eventSink: (ReactionSummaryEvent) -> Unit = {}
) = ReactionSummaryState( ) = ReactionSummaryState(
target = target, target = target,
eventSink = eventSink, eventSink = eventSink,
@ -196,7 +185,7 @@ fun aReactionSummaryState(
fun aCustomReactionState( fun aCustomReactionState(
target: CustomReactionState.Target = CustomReactionState.Target.None, target: CustomReactionState.Target = CustomReactionState.Target.None,
recentEmojis: ImmutableList<String> = persistentListOf(), recentEmojis: ImmutableList<String> = persistentListOf(),
eventSink: (CustomReactionEvents) -> Unit = {}, eventSink: (CustomReactionEvent) -> Unit = {},
) = CustomReactionState( ) = CustomReactionState(
target = target, target = target,
recentEmojis = recentEmojis, recentEmojis = recentEmojis,
@ -206,7 +195,7 @@ fun aCustomReactionState(
fun aReadReceiptBottomSheetState( fun aReadReceiptBottomSheetState(
selectedEvent: TimelineItem.Event? = null, selectedEvent: TimelineItem.Event? = null,
eventSink: (ReadReceiptBottomSheetEvents) -> Unit = {}, eventSink: (ReadReceiptBottomSheetEvent) -> Unit = {},
) = ReadReceiptBottomSheetState( ) = ReadReceiptBottomSheetState(
selectedEvent = selectedEvent, selectedEvent = selectedEvent,
eventSink = eventSink, eventSink = eventSink,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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