Merge pull request #6150 from element-hq/feature/fga/space_ui_tweaks

Iterate on Space related UI
This commit is contained in:
ganfra
2026-02-10 11:36:09 +01:00
committed by GitHub
176 changed files with 695 additions and 583 deletions

View File

@@ -82,10 +82,6 @@ class ConfigureRoomPresenter(
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
init {
dataStore.setIsSpace(isSpace)
}
@Composable
override fun present(): ConfigureRoomState {
val canAddRoomToSpace by featureFlagService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
@@ -123,9 +119,10 @@ class ConfigureRoomPresenter(
} else {
persistentListOf()
}
val parentSpace = spaces.find { it.roomId == initialParentSpaceId }
parentSpace?.let { dataStore.setParentSpace(it) }
parentSpace?.let {
dataStore.setParentSpace(parentSpace = parentSpace, updateVisibility = true)
}
}
LaunchedEffect(cameraPermissionState.permissionGranted) {
@@ -152,21 +149,42 @@ class ConfigureRoomPresenter(
// 2. If it has a parent space.
// 3. If knocking is enabled.
val parentSpace = createRoomConfig.parentSpace
val availableJoinRules = remember(createRoomConfig.parentSpace, isSpace, isKnockFeatureEnabled) {
val availableJoinRules = remember(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,
JoinRuleItem.PrivateVisibility.Private,
).toImmutableList()
else -> listOfNotNull(
JoinRuleItem.PublicVisibility.Restricted(parentSpace.roomId),
JoinRuleItem.PublicVisibility.AskToJoinRestricted(parentSpace.roomId).takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.Private,
JoinRuleItem.PrivateVisibility.Restricted(parentSpace.roomId),
JoinRuleItem.PrivateVisibility.AskToJoinRestricted(parentSpace.roomId).takeIf { isKnockFeatureEnabled },
JoinRuleItem.PrivateVisibility.Private,
).toImmutableList()
}
}
val currentJoinRule = createRoomConfig.visibilityState.joinRuleItem
LaunchedEffect(availableJoinRules, currentJoinRule) {
// Find matching rule by type (ignoring parentSpaceId parameter for Restricted types)
val matchingRule = when (currentJoinRule) {
is JoinRuleItem.PrivateVisibility.Restricted ->
availableJoinRules.filterIsInstance<JoinRuleItem.PrivateVisibility.Restricted>().firstOrNull()
is JoinRuleItem.PrivateVisibility.AskToJoinRestricted ->
availableJoinRules.filterIsInstance<JoinRuleItem.PrivateVisibility.AskToJoinRestricted>().firstOrNull()
else -> availableJoinRules.find { it == currentJoinRule }
}
when {
matchingRule == null -> {
// No matching type fallback to Private (always available)
dataStore.setJoinRule(JoinRuleItem.PrivateVisibility.Private)
}
matchingRule != currentJoinRule -> {
// Same type but different params (e.g., different parentSpaceId), update
dataStore.setJoinRule(matchingRule)
}
}
}
fun createRoom(config: CreateRoomConfig) {
createRoomAction.value = AsyncAction.Uninitialized
@@ -193,7 +211,7 @@ class ConfigureRoomPresenter(
}
}
is ConfigureRoomEvents.SetParentSpace -> {
dataStore.setParentSpace(event.space)
dataStore.setParentSpace(event.space, false)
}
ConfigureRoomEvents.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized
@@ -210,6 +228,7 @@ class ConfigureRoomPresenter(
roomAddressValidity = roomAddressValidity.value,
availableJoinRules = availableJoinRules,
spaces = spaces,
isSpace = isSpace,
eventSink = ::handleEvent,
)
}
@@ -220,35 +239,41 @@ class ConfigureRoomPresenter(
) = launch {
suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it.toUri()) }
val params = if (config.visibilityState is RoomVisibilityState.Public) {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = false,
isDirect = false,
visibility = RoomVisibility.Public,
joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
// No need to specify the public join rule override, since the preset is already PUBLIC_CHAT
.takeIf { it != JoinRule.Public },
preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
roomAliasName = config.visibilityState.roomAddress(),
isSpace = isSpace,
)
} else {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = config.visibilityState is RoomVisibilityState.Private,
isDirect = false,
visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
isSpace = isSpace,
)
val params = when (config.visibilityState) {
is RoomVisibilityState.Public -> {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = false,
isDirect = false,
visibility = RoomVisibility.Public,
joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
// No need to specify the public join rule override, since the preset is already PUBLIC_CHAT
.takeIf { it != JoinRule.Public },
preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
roomAliasName = config.visibilityState.roomAddress(),
isSpace = isSpace,
)
}
is RoomVisibilityState.Private -> {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = true,
isDirect = false,
visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
joinRuleOverride = config.visibilityState.joinRuleItem.toJoinRule()
// No need to specify the Invite join rule override, since the preset is already PRIVATE_CHAT
.takeIf { it != JoinRule.Invite },
preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
isSpace = isSpace,
)
}
}
val roomId = matrixClient.createRoom(params)
.onFailure { failure ->

View File

@@ -17,6 +17,7 @@ import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState(
val isSpace: Boolean,
val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: AsyncAction<RoomId>,
@@ -28,5 +29,6 @@ data class ConfigureRoomState(
val eventSink: (ConfigureRoomEvents) -> Unit
) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true &&
(config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid)
(config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid) &&
config.visibilityState.joinRuleItem in availableJoinRules
}

View File

@@ -82,8 +82,8 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
roomAddressValidity = RoomAddressValidity.Valid,
),
aConfigureRoomState(
isSpace = true,
config = CreateRoomConfig(
isSpace = true,
roomName = "Space 101",
topic = "Space topic for this space when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
visibilityState = RoomVisibilityState.Public(
@@ -95,13 +95,11 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
),
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),
visibilityState = RoomVisibilityState.Private(
joinRuleItem = JoinRuleItem.PrivateVisibility.Restricted(aSpaceRoom().roomId),
),
),
spaces = listOf(aSpaceRoom()),
@@ -109,13 +107,11 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
),
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),
visibilityState = RoomVisibilityState.Private(
joinRuleItem = JoinRuleItem.PrivateVisibility.Restricted(aSpaceRoom().roomId),
),
),
spaces = listOf(aSpaceRoom()),
@@ -126,6 +122,7 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
fun aConfigureRoomState(
config: CreateRoomConfig = CreateRoomConfig(),
isSpace: Boolean = false,
isKnockFeatureEnabled: Boolean = true,
avatarActions: List<AvatarAction> = emptyList(),
createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
@@ -134,21 +131,22 @@ fun aConfigureRoomState(
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,
JoinRuleItem.PrivateVisibility.Restricted(config.parentSpace.roomId),
JoinRuleItem.PrivateVisibility.AskToJoinRestricted(config.parentSpace.roomId).takeIf { isKnockFeatureEnabled },
JoinRuleItem.PrivateVisibility.Private,
)
} else {
listOfNotNull(
JoinRuleItem.PublicVisibility.Public,
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { isKnockFeatureEnabled },
JoinRuleItem.Private,
JoinRuleItem.PrivateVisibility.Private,
)
},
spaces: List<SpaceRoom> = emptyList(),
eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState(
config = config,
isSpace = isSpace,
avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState,

View File

@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -76,7 +78,7 @@ fun ConfigureRoomView(
onCreateRoomSuccess: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
val isSpace = state.config.isSpace
val isSpace = state.isSpace
val focusManager = LocalFocusManager.current
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
@@ -105,7 +107,6 @@ fun ConfigureRoomView(
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RoomNameWithAvatar(
isSpace = isSpace,
@@ -115,20 +116,20 @@ fun ConfigureRoomView(
onAvatarClick = ::onAvatarClick,
onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
Spacer(modifier = Modifier.height(16.dp))
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
if (!state.config.isSpace && state.spaces.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
if (!state.isSpace && state.spaces.isNotEmpty()) {
SelectParentSpaceOptions(
spaces = state.spaces,
selectedSpace = state.config.parentSpace,
onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) },
)
}
RoomJoinRuleOptions(
options = state.availableJoinRules,
selected = state.config.visibilityState.joinRuleItem,
@@ -138,20 +139,17 @@ fun ConfigureRoomView(
state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it))
},
)
if (state.config.visibilityState !is RoomVisibilityState.Private) {
Column {
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
label = null,
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
)
}
ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
label = null,
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
)
}
}
}
@@ -217,7 +215,9 @@ private fun RoomNameWithAvatar(
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.padding(end = 8.dp).size(AvatarSize.EditRoomDetails.dp),
modifier = Modifier
.padding(end = 8.dp)
.size(AvatarSize.EditRoomDetails.dp),
contentAlignment = Alignment.Center,
) {
val avatarState = remember(avatarUri) {
@@ -272,12 +272,13 @@ private fun RoomTopic(
internal fun ConfigureRoomOptions(
title: String,
modifier: Modifier = Modifier,
hasDivider: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.selectableGroup()
) {
ListSectionHeader(title = title)
ListSectionHeader(title = title, hasDivider = hasDivider)
content()
}
}
@@ -302,10 +303,10 @@ private fun RoomJoinRuleOptions(
size = RoundedIconAtomSize.Big,
imageVector = when (item) {
JoinRuleItem.PublicVisibility.Public -> CompoundIcons.Public()
is JoinRuleItem.PublicVisibility.Restricted -> CompoundIcons.Space()
is JoinRuleItem.PrivateVisibility.Restricted -> CompoundIcons.Space()
JoinRuleItem.PublicVisibility.AskToJoin,
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.Private -> CompoundIcons.Lock()
is JoinRuleItem.PrivateVisibility.AskToJoinRestricted -> CompoundIcons.UserAdd()
JoinRuleItem.PrivateVisibility.Private -> CompoundIcons.Lock()
},
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
backgroundTint = Color.Transparent,
@@ -314,28 +315,28 @@ private fun RoomJoinRuleOptions(
headlineContent = {
val title = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
is JoinRuleItem.PublicVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
is JoinRuleItem.PrivateVisibility.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)
JoinRuleItem.PrivateVisibility.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_title)
}
Text(text = title)
},
supportingContent = {
val description = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_description)
is JoinRuleItem.PublicVisibility.Restricted -> stringResource(
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(
R.string.screen_create_room_room_access_section_restricted_option_description,
parentSpace?.displayName.orEmpty()
)
JoinRuleItem.PublicVisibility.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
is JoinRuleItem.PublicVisibility.AskToJoinRestricted -> stringResource(
is JoinRuleItem.PrivateVisibility.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)
JoinRuleItem.PrivateVisibility.Private -> stringResource(R.string.screen_create_room_room_access_section_private_option_description)
}
Text(text = description)
},

View File

@@ -14,11 +14,10 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class CreateRoomConfig(
val isSpace: Boolean = false,
val roomName: String? = null,
val topic: String? = null,
val avatarUri: String? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(),
val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(),
val visibilityState: RoomVisibilityState = RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private),
val parentSpace: SpaceRoom? = null,
)

View File

@@ -72,7 +72,9 @@ class CreateRoomConfigStore(
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
visibilityState = when (joinRule) {
JoinRuleItem.Private -> RoomVisibilityState.Private()
is JoinRuleItem.PrivateVisibility -> RoomVisibilityState.Private(
joinRuleItem = joinRule
)
is JoinRuleItem.PublicVisibility -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public(
@@ -99,17 +101,16 @@ class CreateRoomConfigStore(
}
}
fun setIsSpace(isSpace: Boolean) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(isSpace = isSpace)
}
}
fun setParentSpace(parentSpace: SpaceRoom?) {
fun setParentSpace(parentSpace: SpaceRoom?, updateVisibility: Boolean) {
createRoomConfigFlow.getAndUpdate { config ->
val visibilityState = if (parentSpace != null && updateVisibility) {
RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Restricted(parentSpace.roomId))
} else {
config.visibilityState
}
config.copy(
parentSpace = parentSpace,
visibilityState = RoomVisibilityState.Private(),
visibilityState = visibilityState
)
}
}

View File

@@ -18,7 +18,11 @@ import kotlinx.collections.immutable.persistentListOf
*/
@Immutable
sealed interface JoinRuleItem {
data object Private : JoinRuleItem
sealed interface PrivateVisibility : JoinRuleItem {
data object Private : PrivateVisibility
data class Restricted(val parentSpaceId: RoomId) : PrivateVisibility
data class AskToJoinRestricted(val parentSpaceId: RoomId) : PrivateVisibility
}
/**
* Those join rule items that represent public visibility of the room/space.
@@ -27,18 +31,16 @@ sealed interface JoinRuleItem {
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.Invite
PrivateVisibility.Private -> JoinRule.Invite
is PrivateVisibility.Restricted -> JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
is PrivateVisibility.AskToJoinRestricted -> JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(parentSpaceId)))
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

@@ -12,7 +12,7 @@ import java.util.Optional
sealed interface RoomVisibilityState {
val joinRuleItem: JoinRuleItem
data class Private(override val joinRuleItem: JoinRuleItem.Private = JoinRuleItem.Private) : RoomVisibilityState
data class Private(override val joinRuleItem: JoinRuleItem.PrivateVisibility) : RoomVisibilityState
data class Public(
val roomAddress: RoomAddress,

View File

@@ -8,7 +8,6 @@
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
@@ -21,7 +20,7 @@ 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.compound.theme.ElementTheme
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
@@ -30,7 +29,6 @@ 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
@@ -55,6 +53,7 @@ internal fun SelectParentSpaceOptions(
var displaySelectSpaceBottomSheet by remember { mutableStateOf(false) }
ConfigureRoomOptions(
title = stringResource(CommonStrings.common_space),
hasDivider = false,
modifier = modifier
) {
ListItem(
@@ -62,22 +61,16 @@ internal fun SelectParentSpaceOptions(
Text(
text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
maxLines = 1
maxLines = 1,
color = ElementTheme.colors.textPrimary
)
},
supportingContent = {
Text(
text = if (selectedSpace != null) {
selectedSpace.canonicalAlias?.value.orEmpty()
} else {
stringResource(R.string.screen_create_room_space_selection_no_space_description)
},
maxLines = 1
)
supportingContent = selectedSpace?.canonicalAlias?.let { alias ->
{
Text(text = alias.value, maxLines = 1)
}
},
leadingContent = if (selectedSpace == null) {
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Home()))
} else {
leadingContent = selectedSpace?.let {
ListItemContent.Custom({
val avatarData = AvatarData(
id = selectedSpace.roomId.value,
@@ -119,7 +112,7 @@ internal fun SelectParentSpaceOptions(
}
@Composable
private fun ColumnScope.SelectParentSpaceBottomSheet(
private fun SelectParentSpaceBottomSheet(
spaces: ImmutableList<SpaceRoom>,
selectedSpace: SpaceRoom?,
onSelectSpace: (SpaceRoom?) -> Unit,
@@ -133,19 +126,10 @@ private fun ColumnScope.SelectParentSpaceBottomSheet(
ListItem(
headlineContent = {
Text(
stringResource(R.string.screen_create_room_space_selection_no_space_title),
text = stringResource(R.string.screen_create_room_space_selection_no_space_option),
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
),
@@ -157,29 +141,31 @@ private fun ColumnScope.SelectParentSpaceBottomSheet(
ListItem(
headlineContent = {
Text(
space.displayName,
text = space.displayName,
maxLines = 1
)
},
supportingContent = {
Text(
space.canonicalAlias?.value.orEmpty(),
maxLines = 1
)
supportingContent = space.canonicalAlias?.let { alias ->
{
Text(
text = alias.value,
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()
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
),
@@ -201,7 +187,8 @@ internal fun SelectParentSpaceBottomSheetPreview() =
canonicalAlias = RoomAlias(
"#a-room-alias:example.org"
)
)
),
aSpaceRoom()
),
selectedSpace = null,
) {}

View File

@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.room.join.JoinRule
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
@@ -89,7 +90,7 @@ class ConfigureRoomPresenterTest {
assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private())
assertThat(initialState.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org")
}
@@ -234,7 +235,8 @@ class ConfigureRoomPresenterTest {
matrixClient.givenCreateRoomResult(createRoomResult)
val parentSpace = aSpaceRoom()
// Use a public parent space so AskToJoin is a valid option
val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
@@ -275,7 +277,8 @@ class ConfigureRoomPresenterTest {
matrixClient.givenCreateRoomResult(createRoomResult)
val parentSpace = aSpaceRoom()
// Use a public parent space so AskToJoin is a valid option
val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
@@ -484,16 +487,19 @@ class ConfigureRoomPresenterTest {
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then check changing the parent space resets it to private
// (via LaunchedEffect fallback since Public is not in availableJoinRules for non-public parent)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom()))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
skipItems(1) // Skip intermediate state
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.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)
skipItems(1) // Skip intermediate state (Public is still invalid)
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
// Then remove the parent space, it'll be private again
// Then remove the parent space, the join rule stays private
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(null))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private())
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
}
}

View File

@@ -18,12 +18,12 @@ import org.junit.Test
class JoinRuleItemTest {
@Test
fun `toJoinRule works as expected`() {
assertThat(JoinRuleItem.Private.toJoinRule()).isEqualTo(JoinRule.Invite)
assertThat(JoinRuleItem.PrivateVisibility.Private.toJoinRule()).isEqualTo(JoinRule.Invite)
assertThat(JoinRuleItem.PublicVisibility.Public.toJoinRule()).isEqualTo(JoinRule.Public)
assertThat(JoinRuleItem.PublicVisibility.AskToJoin.toJoinRule()).isEqualTo(JoinRule.Knock)
assertThat(JoinRuleItem.PublicVisibility.Restricted(A_ROOM_ID).toJoinRule())
assertThat(JoinRuleItem.PrivateVisibility.Restricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.Restricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
assertThat(JoinRuleItem.PublicVisibility.AskToJoinRestricted(A_ROOM_ID).toJoinRule())
assertThat(JoinRuleItem.PrivateVisibility.AskToJoinRestricted(A_ROOM_ID).toJoinRule())
.isEqualTo(JoinRule.KnockRestricted(persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))))
}
}

View File

@@ -76,6 +76,7 @@ fun HomeSpacesView(
item {
SpaceHeaderView(
avatarData = space.spaceRoom.getAvatarData(AvatarSize.SpaceHeader),
alias = space.spaceRoom.canonicalAlias,
name = space.spaceRoom.displayName,
topic = space.spaceRoom.topic,
visibility = space.spaceRoom.visibility,

View File

@@ -41,8 +41,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
@@ -514,7 +514,7 @@ private fun IncompleteContent(
title = {
when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> {
RoomPreviewSubtitleAtom(roomIdOrAlias.identifier)
RoomPreviewAliasAtom(roomIdOrAlias.identifier)
}
is RoomIdOrAlias.Id -> {
PlaceholderAtom(width = 200.dp, height = 22.dp)
@@ -566,13 +566,12 @@ private fun DefaultLoadedContent(
}
},
subtitle = {
when {
contentState.details is LoadedDetails.Space -> {
SpaceInfoRow(visibility = SpaceRoomVisibility.fromJoinRule(contentState.joinRule))
}
contentState.alias != null -> {
RoomPreviewSubtitleAtom(contentState.alias.value)
}
if (contentState.alias != null) {
RoomPreviewAliasAtom(contentState.alias.value)
}
if (contentState.details is LoadedDetails.Space) {
Spacer(Modifier.height(8.dp))
SpaceInfoRow(visibility = SpaceRoomVisibility.fromJoinRule(contentState.joinRule))
}
},
description = {

View File

@@ -25,7 +25,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -54,7 +54,7 @@ fun RoomAliasResolverView(
containerColor = Color.Transparent,
contentPadding = PaddingValues(
horizontal = 16.dp,
vertical = 32.dp
vertical = 24.dp
),
topBar = {
RoomAliasResolverTopBar(onBackClick = onBackClick)
@@ -121,7 +121,7 @@ private fun RoomAliasResolverContent(
PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp)
},
title = {
RoomPreviewSubtitleAtom(roomAlias.value)
RoomPreviewAliasAtom(roomAlias.value)
},
subtitle = {
if (isLoading) {

View File

@@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(FlowPreview::class)
package io.element.android.features.space.impl.root
import androidx.compose.runtime.Composable
@@ -47,10 +49,13 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject
class SpacePresenter(
@@ -80,13 +85,16 @@ class SpacePresenter(
val localCoroutineScope = rememberCoroutineScope()
val hasMoreToLoad by remember {
spaceRoomList.paginationStatusFlow.mapState { status ->
when (status) {
is SpaceRoomList.PaginationStatus.Idle -> status.hasMoreToLoad
SpaceRoomList.PaginationStatus.Loading -> true
spaceRoomList.paginationStatusFlow
.mapState { status ->
when (status) {
is SpaceRoomList.PaginationStatus.Idle -> status.hasMoreToLoad
SpaceRoomList.PaginationStatus.Loading -> true
}
}
}
}.collectAsState()
// Debounce to give more time for spaceRoomList to updates
.debounce(100.milliseconds)
}.collectAsState(true)
val permissions by room.permissionsAsState(SpacePermissions.DEFAULT) { perms ->
perms.spacePermissions()

View File

@@ -50,6 +50,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.BigIcon
@@ -76,6 +77,7 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
@@ -170,6 +172,7 @@ fun SpaceView(
state.eventSink(SpaceEvents.ShowTopicViewer(topic))
},
onCreateRoomClick = onCreateRoomClick,
onAddRoomClick = onAddRoomClick,
)
JoinFailuresEffect(
hasAnyFailure = state.hasAnyJoinFailures,
@@ -243,6 +246,7 @@ private fun SpaceViewContent(
onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onTopicClick: (String) -> Unit,
onCreateRoomClick: () -> Unit,
onAddRoomClick: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier.fillMaxSize()) {
@@ -256,6 +260,7 @@ private fun SpaceViewContent(
Column {
SpaceHeaderView(
avatarData = spaceInfo.getAvatarData(AvatarSize.SpaceHeader),
alias = spaceInfo.canonicalAlias,
name = spaceInfo.name,
topic = spaceInfo.topic,
topicMaxLines = 2,
@@ -271,7 +276,10 @@ private fun SpaceViewContent(
if (state.children.isEmpty() && state.canEditSpaceGraph && !state.hasMoreToLoad) {
item {
EmptySpaceView(onCreateRoomClick = onCreateRoomClick)
EmptySpaceView(
onCreateRoomClick = onCreateRoomClick,
onAddRoomClick = onAddRoomClick,
)
}
} else {
itemsIndexed(
@@ -332,7 +340,10 @@ private fun SpaceViewContent(
}
@Composable
private fun EmptySpaceView(onCreateRoomClick: () -> Unit) {
private fun EmptySpaceView(
onCreateRoomClick: () -> Unit,
onAddRoomClick: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(bottom = 24.dp),
@@ -340,15 +351,25 @@ private fun EmptySpaceView(onCreateRoomClick: () -> Unit) {
IconTitleSubtitleMolecule(
title = stringResource(R.string.screen_space_empty_state_title),
subTitle = null,
iconStyle = BigIcon.Style.Default(CompoundIcons.Room()),
iconStyle = BigIcon.Style.Default(vectorIcon = CompoundIcons.Room(), usePrimaryTint = true),
modifier = Modifier.fillMaxWidth()
.padding(top = 40.dp, start = 24.dp, end = 24.dp, bottom = 24.dp),
)
Button(
text = stringResource(R.string.screen_space_add_room_action),
leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
onClick = onCreateRoomClick,
)
ButtonColumnMolecule(
modifier = Modifier.padding(horizontal = 16.dp)
) {
Button(
text = stringResource(CommonStrings.action_add_existing_rooms),
leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
onClick = onAddRoomClick,
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
text = stringResource(CommonStrings.action_create_room),
onClick = onCreateRoomClick,
modifier = Modifier.fillMaxWidth()
)
}
}
}

View File

@@ -15,7 +15,6 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
@@ -215,9 +214,25 @@ class SpaceViewTest {
),
onCreateRoomClick = onCreateRoomClick,
)
rule.clickOn(R.string.screen_space_add_room_action)
rule.clickOn(CommonStrings.action_create_room)
onCreateRoomClick.assertions().isCalledOnce()
}
@Test
fun `clicking add existing room button calls the expected callback`() {
val onAddRoomClick = lambdaRecorder<Unit> { }
rule.setSpaceView(
aSpaceState(
children = emptyList(),
hasMoreToLoad = false,
isManageMode = true,
canManageRooms = true,
),
onAddRoomClick = onAddRoomClick,
)
rule.clickOn(CommonStrings.action_add_existing_rooms)
onAddRoomClick.assertions().isCalledOnce()
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceView(

View File

@@ -0,0 +1,83 @@
/*
* 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.libraries.designsystem.atomic.atoms
import android.content.ClipData
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.toClipEntry
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@Composable
fun RoomPreviewAliasAtom(
alias: String,
modifier: Modifier = Modifier,
copiable: Boolean = true
) {
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
Row(
modifier = modifier
.clickable(enabled = copiable) {
coroutineScope.launch {
val clipData = ClipData.newPlainText(alias, alias)
clipboard.setClipEntry(clipData.toClipEntry())
}
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = alias,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textSecondary,
)
if (copiable) {
Icon(
imageVector = CompoundIcons.Copy(),
contentDescription = stringResource(id = CommonStrings.action_copy),
tint = ElementTheme.colors.iconSecondaryAlpha,
modifier = Modifier.size(ElementTheme.typography.fontBodyLgRegular.fontSize.toDp())
)
}
}
}
@PreviewsDayNight
@Composable
internal fun RoomPreviewAliasAtomPreview() = ElementPreview {
RoomPreviewAliasAtom(
alias = "#room-alias:matrix.org",
copiable = true
)
}

View File

@@ -1,26 +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.libraries.designsystem.atomic.atoms
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPreviewSubtitleAtom(subtitle: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
)
}

View File

@@ -9,6 +9,7 @@
package io.element.android.libraries.designsystem.atomic.organisms
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -19,12 +20,12 @@ import androidx.compose.ui.unit.dp
@Composable
fun RoomPreviewOrganism(
avatar: @Composable () -> Unit,
title: @Composable () -> Unit,
subtitle: @Composable () -> Unit,
avatar: @Composable ColumnScope.() -> Unit,
title: @Composable ColumnScope.() -> Unit,
subtitle: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
description: @Composable (() -> Unit)? = null,
memberCount: @Composable (() -> Unit)? = null,
description: @Composable (ColumnScope.() -> Unit)? = null,
memberCount: @Composable (ColumnScope.() -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth(),

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.designsystem.components.avatar.internal
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -16,6 +17,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
@@ -34,19 +36,26 @@ internal fun SpaceAvatar(
contentDescription: String? = null,
) {
val size = forcedAvatarSize ?: avatarData.size.dp
val avatarShape = avatarType.avatarShape(size)
val commonModifier = modifier
.border(
width = 1.dp,
color = ElementTheme.colors.iconQuaternaryAlpha,
shape = avatarShape,
)
when {
avatarType.isTombstoned -> TombstonedRoomAvatar(
size = size,
avatarShape = avatarType.avatarShape(size),
modifier = modifier,
avatarShape = avatarShape,
modifier = commonModifier,
contentDescription = contentDescription,
)
else -> InitialOrImageAvatar(
avatarData = avatarData,
hideAvatarImage = hideAvatarImage,
avatarShape = avatarType.avatarShape(size),
avatarShape = avatarShape,
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
modifier = commonModifier,
contentDescription = contentDescription,
)
}

View File

@@ -14,12 +14,12 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule
sealed interface SpaceRoomVisibility {
data object Private : SpaceRoomVisibility
data object Public : SpaceRoomVisibility
data object Restricted : SpaceRoomVisibility
data object SpaceMembers : SpaceRoomVisibility
companion object {
fun fromJoinRule(joinRule: JoinRule?): SpaceRoomVisibility = when (joinRule) {
JoinRule.Public -> Public
is JoinRule.Restricted, is JoinRule.KnockRestricted -> Restricted
is JoinRule.Restricted, is JoinRule.KnockRestricted -> SpaceMembers
// Else fallback to Private
else -> Private
}

View File

@@ -9,13 +9,17 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
@@ -26,6 +30,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
@@ -39,6 +44,7 @@ import kotlinx.collections.immutable.persistentListOf
fun SpaceHeaderView(
avatarData: AvatarData,
name: String?,
alias: RoomAlias?,
topic: String?,
visibility: SpaceRoomVisibility,
heroes: ImmutableList<MatrixUser>,
@@ -66,7 +72,15 @@ fun SpaceHeaderView(
}
},
subtitle = {
SpaceInfoRow(visibility = visibility)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (alias != null) {
RoomPreviewAliasAtom(alias = alias.value)
}
SpaceInfoRow(visibility = visibility)
}
},
description = if (topic.isNullOrBlank()) {
null
@@ -100,6 +114,7 @@ internal fun SpaceHeaderViewPreview() = ElementPreview {
url = "anUrl",
size = AvatarSize.SpaceHeader,
),
alias = RoomAlias("#spaceAlias:matrix.org"),
name = "Space name",
topic = "Space topic: " + LoremIpsum(40).values.first(),
topicMaxLines = 2,

View File

@@ -117,7 +117,7 @@ internal fun SpaceInfoRowPreview() = ElementPreview {
visibility = SpaceRoomVisibility.Public
)
SpaceInfoRow(
visibility = SpaceRoomVisibility.Restricted
visibility = SpaceRoomVisibility.SpaceMembers
)
}
}

View File

@@ -24,11 +24,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -100,22 +98,16 @@ fun SpaceRoomItemView(
showIndicator = showUnreadIndicator
)
Spacer(modifier = Modifier.height(1.dp))
SubtitleRow(
visibilityIcon = spaceRoom.visibilityIcon(),
subtitle = spaceRoom.subtitle()
)
VisibilityRow(visibility = spaceRoom.visibility)
Spacer(modifier = Modifier.height(1.dp))
val info = spaceRoom.info()
if (info.isNotBlank()) {
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = info,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = pluralStringResource(CommonPlurals.common_member_count, spaceRoom.numJoinedMembers, spaceRoom.numJoinedMembers),
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (bottomAction != null) {
Spacer(modifier = Modifier.height(12.dp))
@@ -129,29 +121,26 @@ fun SpaceRoomItemView(
}
@Composable
private fun SubtitleRow(
visibilityIcon: ImageVector?,
subtitle: String,
private fun VisibilityRow(
visibility: SpaceRoomVisibility,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (visibilityIcon != null) {
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = visibilityIcon,
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = visibility.icon,
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = subtitle,
text = visibility.label,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -219,36 +208,6 @@ private fun SpaceRoomItemScaffold(
}
}
@Composable
@ReadOnlyComposable
private fun SpaceRoom.subtitle(): String {
return if (isSpace) {
visibility.label
} else {
pluralStringResource(CommonPlurals.common_member_count, numJoinedMembers, numJoinedMembers)
}
}
@Composable
@ReadOnlyComposable
private fun SpaceRoom.info(): String {
return if (isSpace) {
pluralStringResource(CommonPlurals.common_member_count, numJoinedMembers, numJoinedMembers)
} else {
topic.orEmpty()
}
}
@Composable
private fun SpaceRoom.visibilityIcon(): ImageVector? {
// Don't show any icon for restricted rooms as it's the default and would add noise
return if (visibility == SpaceRoomVisibility.Restricted) {
null
} else {
visibility.icon
}
}
@Composable
@PreviewsDayNight
internal fun SpaceRoomItemViewPreview(@PreviewParameter(SpaceRoomProvider::class) spaceRoom: SpaceRoom) = ElementPreview {

View File

@@ -32,7 +32,7 @@ val SpaceRoomVisibility.icon: ImageVector
return when (this) {
SpaceRoomVisibility.Private -> CompoundIcons.LockSolid()
SpaceRoomVisibility.Public -> CompoundIcons.Public()
SpaceRoomVisibility.Restricted -> CompoundIcons.Space()
SpaceRoomVisibility.SpaceMembers -> CompoundIcons.Space()
}
}
@@ -41,8 +41,8 @@ val SpaceRoomVisibility.label: String
@ReadOnlyComposable
get() {
return when (this) {
SpaceRoomVisibility.Private -> stringResource(CommonStrings.common_private_space)
SpaceRoomVisibility.Public -> stringResource(CommonStrings.common_public_space)
SpaceRoomVisibility.Restricted -> stringResource(CommonStrings.common_shared_space)
SpaceRoomVisibility.Private -> stringResource(CommonStrings.common_private)
SpaceRoomVisibility.Public -> stringResource(CommonStrings.common_public)
SpaceRoomVisibility.SpaceMembers -> stringResource(CommonStrings.common_space_members)
}
}

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