design (join room) : update design of join room screen

This commit is contained in:
ganfra 2025-09-16 14:51:02 +02:00
parent e79281a78a
commit c44bf89ed5
12 changed files with 198 additions and 148 deletions

View file

@ -101,7 +101,6 @@ sealed interface ContentState {
sealed interface JoinAuthorisationStatus {
data object None : JoinAuthorisationStatus
data class IsSpace(val applicationName: String) : JoinAuthorisationStatus
data class IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus
data class IsBanned(val banSender: InviteSender?, val reason: String?) : JoinAuthorisationStatus
data object IsKnocked : JoinAuthorisationStatus

View file

@ -7,21 +7,20 @@
package io.element.android.features.joinroom.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
@ -38,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
@ -65,14 +65,21 @@ 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.ButtonSize
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.placeholderBackground
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.ui.components.SpaceInfoRow
import io.element.android.libraries.matrix.ui.components.SpaceMembersView
import io.element.android.libraries.matrix.ui.model.InviteSender
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun JoinRoomView(
@ -92,7 +99,7 @@ fun JoinRoomView(
containerColor = Color.Transparent,
contentPadding = PaddingValues(
horizontal = 16.dp,
vertical = 32.dp
vertical = 24.dp
),
topBar = {
JoinRoomTopBar(
@ -220,12 +227,14 @@ private fun JoinRoomFooter(
onClick = { onDeclineInvite(joinAuthorisationStatus.inviteData, false) },
modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
leadingIcon = IconSource.Vector(CompoundIcons.Close())
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = { onAcceptInvite(joinAuthorisationStatus.inviteData) },
modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
leadingIcon = IconSource.Vector(CompoundIcons.Check())
)
}
Spacer(modifier = Modifier.height(24.dp))
@ -278,7 +287,6 @@ private fun JoinRoomFooter(
JoinAuthorisationStatus.Unknown -> JoinRestrictedFooter(onJoinRoom)
JoinAuthorisationStatus.Restricted -> JoinRestrictedFooter(onJoinRoom)
JoinAuthorisationStatus.Unauthorized -> JoinUnauthorizedFooter(onGoBack)
is JoinAuthorisationStatus.IsSpace -> UnsupportedSpaceFooter(joinAuthorisationStatus.applicationName, onGoBack)
JoinAuthorisationStatus.None -> Unit
}
}
@ -397,19 +405,40 @@ private fun JoinRoomContent(
IsKnockedLoadedContent()
}
else -> {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
if (inviteSender != null) {
InviteSenderView(inviteSender = inviteSender, hideAvatarImage = hideAvatarsImages)
Spacer(modifier = Modifier.height(32.dp))
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.verticalScroll(rememberScrollState())
) {
DefaultLoadedContent(
modifier = Modifier.verticalScroll(rememberScrollState()),
contentState = contentState,
knockMessage = knockMessage,
hideAvatarImage = hideAvatarsImages,
onKnockMessageUpdate = onKnockMessageUpdate
)
when (contentState.joinAuthorisationStatus) {
is JoinAuthorisationStatus.IsInvited -> {
val inviteSender = contentState.joinAuthorisationStatus.inviteSender
if (inviteSender != null) {
Spacer(Modifier.height(16.dp))
InvitedByView(inviteSender, hideAvatarsImages)
}
}
is JoinAuthorisationStatus.CanKnock -> {
Spacer(modifier = Modifier.height(24.dp))
val supportingText = if (knockMessage.isNotEmpty()) {
"${knockMessage.length}/$MAX_KNOCK_MESSAGE_LENGTH"
} else {
stringResource(R.string.screen_join_room_knock_message_description)
}
TextField(
value = knockMessage,
onValueChange = onKnockMessageUpdate,
maxLines = 3,
minLines = 3,
modifier = Modifier.fillMaxWidth(),
supportingText = supportingText
)
}
else -> Unit
}
}
}
}
@ -422,6 +451,45 @@ private fun JoinRoomContent(
}
}
@Composable
private fun InvitedByView(
sender: InviteSender,
hideAvatarImage: Boolean,
modifier: Modifier = Modifier
) {
Column(
modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Invited by",
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary
)
Spacer(Modifier.height(8.dp))
Avatar(
avatarData = sender.avatarData,
avatarType = AvatarType.User,
hideImage = hideAvatarImage,
forcedAvatarSize = AvatarSize.RoomPreviewInviter.dp
)
Spacer(Modifier.height(8.dp))
Text(
text = sender.displayName,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary
)
Spacer(Modifier.height(4.dp))
Text(
text = sender.userId.value,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary
)
}
}
@Composable
private fun UnknownRoomContent(
modifier: Modifier = Modifier
@ -429,7 +497,21 @@ private fun UnknownRoomContent(
RoomPreviewOrganism(
modifier = modifier,
avatar = {
Spacer(modifier = Modifier.size(AvatarSize.RoomHeader.dp))
Box(
modifier = modifier
.size(AvatarSize.RoomPreviewHeader.dp)
.background(
color = ElementTheme.colors.placeholderBackground,
shape = CircleShape
)
) {
Icon(
modifier = Modifier.align(Alignment.Center),
tint = ElementTheme.colors.iconPrimary,
imageVector = CompoundIcons.VisibilityOff(),
contentDescription = null,
)
}
},
title = {
RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview))
@ -448,7 +530,7 @@ private fun IncompleteContent(
RoomPreviewOrganism(
modifier = modifier,
avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp)
},
title = {
when (roomIdOrAlias) {
@ -471,43 +553,32 @@ private fun IncompleteContent(
@Composable
private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) {
BoxWithConstraints(
modifier = modifier
.fillMaxHeight()
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
IconTitleSubtitleMolecule(
modifier = Modifier.sizeIn(minHeight = maxHeight * 0.7f),
iconStyle = BigIcon.Style.SuccessSolid,
title = stringResource(R.string.screen_join_room_knock_sent_title),
subTitle = stringResource(R.string.screen_join_room_knock_sent_description),
)
}
IconTitleSubtitleMolecule(
modifier = modifier.padding(horizontal = 8.dp),
iconStyle = BigIcon.Style.SuccessSolid,
title = stringResource(R.string.screen_join_room_knock_sent_title),
subTitle = stringResource(R.string.screen_join_room_knock_sent_description),
)
}
@Composable
private fun DefaultLoadedContent(
contentState: ContentState.Loaded,
knockMessage: String,
hideAvatarImage: Boolean,
onKnockMessageUpdate: (String) -> Unit,
modifier: Modifier = Modifier,
) {
RoomPreviewOrganism(
modifier = modifier,
avatar = {
Avatar(
contentState.avatarData(AvatarSize.RoomHeader),
contentState.avatarData(AvatarSize.RoomPreviewHeader),
hideImage = hideAvatarImage,
avatarType = AvatarType.Room(),
avatarType = if (contentState.isSpace) AvatarType.Space() else AvatarType.Room(),
)
},
title = {
if (contentState.name != null) {
RoomPreviewTitleAtom(
title = contentState.name,
)
RoomPreviewTitleAtom(title = contentState.name)
} else {
RoomPreviewTitleAtom(
title = stringResource(id = CommonStrings.common_no_room_name),
@ -516,37 +587,32 @@ private fun DefaultLoadedContent(
}
},
subtitle = {
if (contentState.alias != null) {
RoomPreviewSubtitleAtom(contentState.alias.value)
}
},
description = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
RoomPreviewDescriptionAtom(contentState.topic ?: "")
if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanKnock) {
Spacer(modifier = Modifier.height(24.dp))
val supportingText = if (knockMessage.isNotEmpty()) {
"${knockMessage.length}/$MAX_KNOCK_MESSAGE_LENGTH"
} else {
stringResource(R.string.screen_join_room_knock_message_description)
}
TextField(
value = knockMessage,
onValueChange = onKnockMessageUpdate,
maxLines = 3,
minLines = 3,
modifier = Modifier.fillMaxWidth(),
supportingText = supportingText
when {
contentState.isSpace -> {
SpaceInfoRow(
joinRule = contentState.joinRule ?: JoinRule.Public,
numberOfRooms = contentState.childrenCount ?: 0,
)
}
contentState.alias != null -> {
RoomPreviewSubtitleAtom(contentState.alias.value)
}
}
},
description = {
RoomPreviewDescriptionAtom(
contentState.topic ?: "",
maxLines = if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanJoin) Int.MAX_VALUE else 2
)
},
memberCount = {
if (contentState.showMemberCount) {
MembersCountMolecule(memberCount = contentState.numberOfMembers?.toInt() ?: 0)
val membersCount = contentState.numberOfMembers?.toInt() ?: 0
if (contentState.isSpace) {
SpaceMembersView(persistentListOf(), membersCount)
} else {
MembersCountMolecule(memberCount = membersCount)
}
}
}
)

View file

@ -655,7 +655,10 @@ class JoinRoomPresenterTest {
isDm = false,
roomType = RoomType.Room,
roomAvatarUrl = "avatarUrl",
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin,
joinRule = JoinRule.Public,
childrenCount = null,
heroes = persistentListOf()
)
)
}
@ -724,7 +727,10 @@ class JoinRoomPresenterTest {
),
membershipChangeReason = null,
),
)
),
joinRule = JoinRule.Public,
childrenCount = null,
heroes = persistentListOf()
)
)
}
@ -789,7 +795,10 @@ class JoinRoomPresenterTest {
membershipChangeReason = null,
),
reason = null,
)
),
joinRule = JoinRule.Public,
childrenCount = null,
heroes = persistentListOf()
)
)
}
@ -842,7 +851,10 @@ class JoinRoomPresenterTest {
isDm = false,
roomType = RoomType.Room,
roomAvatarUrl = "avatarUrl",
joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked
joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked,
joinRule = JoinRule.Public,
childrenCount = null,
heroes = persistentListOf()
)
)
}
@ -949,26 +961,6 @@ class JoinRoomPresenterTest {
}
}
@Test
fun `present - when room is not known RoomPreview is loaded as Space`() = runTest {
val client = FakeMatrixClient(
getNotJoinedRoomResult = { _, _ ->
Result.success(
aRoomPreview(info = aRoomPreviewInfo(isSpace = true))
)
}
)
val presenter = createJoinRoomPresenter(
matrixClient = client
)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.UnsupportedSpace("AppName"))
}
}
}
@Test
fun `present - when room is not known RoomPreview is loaded with error`() = runTest {
val client = FakeMatrixClient(

View file

@ -117,7 +117,7 @@ private fun RoomAliasResolverContent(
RoomPreviewOrganism(
modifier = modifier,
avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
PlaceholderAtom(width = AvatarSize.RoomPreviewHeader.dp, height = AvatarSize.RoomPreviewHeader.dp)
},
title = {
RoomPreviewSubtitleAtom(roomAlias.value)

View file

@ -396,10 +396,10 @@ private fun RoomHeaderSection(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomDetailsHeader),
avatarType = AvatarType.Room(
heroes = heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomHeader)
user.getAvatarData(size = AvatarSize.RoomDetailsHeader)
}.toPersistentList(),
isTombstoned = isTombstoned,
),

View file

@ -15,14 +15,18 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPreviewDescriptionAtom(description: String, modifier: Modifier = Modifier) {
fun RoomPreviewDescriptionAtom(
description: String,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
) {
Text(
modifier = modifier,
text = description,
style = ElementTheme.typography.fontBodySmRegular,
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
maxLines = 3,
color = ElementTheme.colors.textPrimary,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
)
}

View file

@ -18,7 +18,7 @@ fun RoomPreviewSubtitleAtom(subtitle: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = subtitle,
style = ElementTheme.typography.fontBodyMdRegular,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
)

View file

@ -23,7 +23,7 @@ fun RoomPreviewTitleAtom(
Text(
modifier = modifier,
text = title,
style = ElementTheme.typography.fontHeadingMdBold,
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
fontStyle = fontStyle,
color = ElementTheme.colors.textPrimary,

View file

@ -34,14 +34,13 @@ fun RoomPreviewOrganism(
title()
Spacer(modifier = Modifier.height(8.dp))
subtitle()
Spacer(modifier = Modifier.height(8.dp))
if (memberCount != null) {
Spacer(modifier = Modifier.height(8.dp))
memberCount()
}
Spacer(modifier = Modifier.height(8.dp))
if (description != null) {
Spacer(modifier = Modifier.height(16.dp))
description()
}
Spacer(modifier = Modifier.height(24.dp))
}
}

View file

@ -14,7 +14,7 @@ enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
IncomingCall(140.dp),
RoomHeader(96.dp),
RoomDetailsHeader(96.dp),
RoomListItem(52.dp),
SpaceListItem(52.dp),
@ -68,5 +68,7 @@ enum class AvatarSize(val dp: Dp) {
OrganizationHeader(64.dp),
SpaceHeader(64.dp),
RoomPreviewHeader(64.dp),
RoomPreviewInviter(56.dp),
SpaceMember(24.dp),
}

View file

@ -7,19 +7,14 @@
package io.element.android.libraries.matrix.ui.components
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
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
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
@ -47,47 +42,40 @@ fun SpaceHeaderView(
modifier: Modifier = Modifier,
topicMaxLines: Int = Int.MAX_VALUE,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 24.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space(false),
)
name?.let {
Text(
text = name,
style = ElementTheme.typography.fontHeadingLgBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
RoomPreviewOrganism(
modifier = modifier.padding(24.dp),
avatar = {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space(),
)
}
if (joinRule != null) {
SpaceInfoRow(
joinRule = joinRule,
numberOfRooms = numberOfRooms,
},
title = {
name?.let {
RoomPreviewTitleAtom(title = name)
}
},
subtitle = {
if (joinRule != null) {
SpaceInfoRow(
joinRule = joinRule,
numberOfRooms = numberOfRooms,
)
}
},
description = if (topic != null) {
{ RoomPreviewDescriptionAtom(description = topic, maxLines = topicMaxLines) }
} else {
null
},
memberCount = {
SpaceMembersView(
heroes = heroes,
numberOfMembers = numberOfMembers,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
SpaceMembersView(
heroes = heroes,
numberOfMembers = numberOfMembers,
modifier = Modifier.padding(horizontal = 32.dp),
)
topic?.let {
Text(
text = topic,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
maxLines = topicMaxLines,
overflow = TextOverflow.Ellipsis,
)
}
}
},
)
}
@PreviewsDayNight

View file

@ -105,7 +105,7 @@ class DefaultNotificationConversationService(
targetSize = defaultShortcutIconSize.toLong()
)?.let(IconCompat::createWithBitmap)
?: InitialsAvatarBitmapGenerator(useDarkTheme = useDarkTheme)
.generateBitmap(defaultShortcutIconSize, AvatarData(id = roomId.value, name = roomName, size = AvatarSize.RoomHeader))
.generateBitmap(defaultShortcutIconSize, AvatarData(id = roomId.value, name = roomName, size = AvatarSize.RoomDetailsHeader))
?.let(IconCompat::createWithAdaptiveBitmap)
val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId))