First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.matrix.ui"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.di)
implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(libs.coil.compose)
implementation(libs.jsoup)
implementation(projects.libraries.previewutils)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.sessionStorage.test)
}
@@ -0,0 +1,152 @@
/*
* 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.libraries.matrix.ui.components
import android.os.Parcelable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.PinIcon
import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.parcelize.Parcelize
@Composable
fun AttachmentThumbnail(
info: AttachmentThumbnailInfo,
modifier: Modifier = Modifier,
thumbnailSize: Long = 32L,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
) {
if (info.thumbnailSource != null) {
val mediaRequestData = MediaRequestData(
source = info.thumbnailSource,
kind = MediaRequestData.Kind.Thumbnail(thumbnailSize),
)
BlurHashAsyncImage(
model = mediaRequestData,
blurHash = info.blurHash,
contentDescription = info.textContent,
contentScale = ContentScale.Crop,
modifier = modifier,
)
} else if (info.blurHash != null) {
BlurHashAsyncImage(
model = null,
blurHash = info.blurHash,
contentDescription = info.textContent,
contentScale = ContentScale.Crop,
modifier = modifier,
)
} else {
Box(
modifier = modifier.background(backgroundColor),
contentAlignment = Alignment.Center
) {
when (info.type) {
AttachmentThumbnailType.Image -> {
Icon(
imageVector = CompoundIcons.Image(),
contentDescription = info.textContent,
)
}
AttachmentThumbnailType.Video -> {
Icon(
imageVector = CompoundIcons.VideoCall(),
contentDescription = info.textContent,
)
}
AttachmentThumbnailType.Audio -> {
Icon(
imageVector = CompoundIcons.Audio(),
contentDescription = info.textContent,
)
}
AttachmentThumbnailType.Voice -> {
Icon(
imageVector = CompoundIcons.MicOnSolid(),
contentDescription = info.textContent,
)
}
AttachmentThumbnailType.File -> {
Icon(
imageVector = CompoundIcons.Attachment(),
contentDescription = info.textContent,
modifier = Modifier.rotate(-45f)
)
}
AttachmentThumbnailType.Location -> {
PinIcon(
modifier = Modifier.fillMaxSize()
)
/*
// For coherency across the app, we should us this instead. Waiting for design decision.
Icon(
resourceId = R.drawable.ic_september_location,
contentDescription = info.textContent,
)
*/
}
AttachmentThumbnailType.Poll -> {
Icon(
imageVector = CompoundIcons.Polls(),
contentDescription = info.textContent,
)
}
}
}
}
}
@Parcelize
enum class AttachmentThumbnailType : Parcelable {
Image,
Video,
File,
Audio,
Location,
Voice,
Poll,
}
@Parcelize
data class AttachmentThumbnailInfo(
val type: AttachmentThumbnailType,
val thumbnailSource: MediaSource? = null,
val textContent: String? = null,
val blurHash: String? = null,
) : Parcelable
@PreviewsDayNight
@Composable
internal fun AttachmentThumbnailPreview(@PreviewParameter(AttachmentThumbnailInfoProvider::class) data: AttachmentThumbnailInfo) = ElementPreview {
AttachmentThumbnail(
info = data,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp))
)
}
@@ -0,0 +1,42 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.media.MediaSource
open class AttachmentThumbnailInfoProvider : PreviewParameterProvider<AttachmentThumbnailInfo> {
override val values: Sequence<AttachmentThumbnailInfo>
get() = sequenceOf(
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Image),
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Image, blurHash = A_BLUR_HASH),
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Video),
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Video, blurHash = A_BLUR_HASH),
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Audio),
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.File),
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Location),
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Voice),
anAttachmentThumbnailInfo(type = AttachmentThumbnailType.Poll),
)
}
fun anAttachmentThumbnailInfo(
type: AttachmentThumbnailType,
thumbnailSource: MediaSource? = null,
textContent: String? = null,
blurHash: String? = null,
) =
AttachmentThumbnailInfo(
type = type,
thumbnailSource = thumbnailSource,
textContent = textContent,
blurHash = blurHash,
)
const val A_BLUR_HASH = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr"
@@ -0,0 +1,122 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.matrix.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.theme.ElementTheme
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.ListItemStyle
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.ui.media.AvatarAction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AvatarActionBottomSheet(
actions: ImmutableList<AvatarAction>,
isVisible: Boolean,
onSelectAction: (action: AvatarAction) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
BackHandler(enabled = isVisible) {
sheetState.hide(coroutineScope, then = { onDismiss() })
}
fun onItemActionClick(itemAction: AvatarAction) {
onSelectAction(itemAction)
sheetState.hide(coroutineScope, then = { onDismiss() })
}
if (isVisible) {
ModalBottomSheet(
onDismissRequest = {
sheetState.hide(coroutineScope, then = { onDismiss() })
},
modifier = modifier,
sheetState = sheetState,
) {
AvatarActionBottomSheetContent(
actions = actions,
onActionClick = ::onItemActionClick,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
)
}
}
}
@Composable
private fun AvatarActionBottomSheetContent(
actions: ImmutableList<AvatarAction>,
modifier: Modifier = Modifier,
onActionClick: (AvatarAction) -> Unit = { },
) {
LazyColumn(
modifier = modifier.fillMaxWidth()
) {
items(
items = actions,
) { action ->
ListItem(
modifier = Modifier.clickable { onActionClick(action) },
headlineContent = {
Text(
text = stringResource(action.titleResId),
style = ElementTheme.typography.fontBodyLgRegular,
color = if (action.destructive) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary,
)
},
leadingContent = ListItemContent.Icon(IconSource.Resource(action.iconResourceId)),
style = when {
action.destructive -> ListItemStyle.Destructive
else -> ListItemStyle.Primary
}
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AvatarActionBottomSheetPreview() = ElementPreview {
AvatarActionBottomSheet(
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
isVisible = true,
onSelectAction = { },
onDismiss = { },
)
}
@@ -0,0 +1,161 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.SelectedIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.matrix.ui.model.getAvatarData
@Composable
fun CheckableUserRow(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
data: CheckableUserRowData,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(role = Role.Checkbox, enabled = enabled) {
onCheckedChange(!checked)
},
verticalAlignment = Alignment.CenterVertically,
) {
val rowModifier = Modifier.weight(1f)
when (data) {
is CheckableUserRowData.Resolved -> {
UserRow(
modifier = rowModifier,
avatarData = data.avatarData,
name = data.name,
subtext = data.subtext,
enabled = enabled,
)
}
is CheckableUserRowData.Unresolved -> {
UnresolvedUserRow(
modifier = rowModifier,
avatarData = data.avatarData,
id = data.id,
enabled = enabled,
)
}
}
SelectedIndicatorAtom(
modifier = Modifier.padding(end = 24.dp),
checked = checked,
enabled = enabled,
)
}
}
@Immutable
sealed interface CheckableUserRowData {
data class Resolved(
val avatarData: AvatarData,
val name: String,
val subtext: String?,
) : CheckableUserRowData
data class Unresolved(
val avatarData: AvatarData,
val id: String,
) : CheckableUserRowData
}
@Preview
@Composable
internal fun CheckableResolvedUserRowPreview() = ElementThemedPreview {
val matrixUser = aMatrixUser()
val data = CheckableUserRowData.Resolved(
avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem),
name = matrixUser.displayName.orEmpty(),
subtext = matrixUser.userId.value,
)
Column {
CheckableUserRow(
checked = false,
onCheckedChange = { },
data = data,
)
HorizontalDivider()
CheckableUserRow(
checked = true,
onCheckedChange = { },
data = data,
)
HorizontalDivider()
CheckableUserRow(
checked = false,
onCheckedChange = { },
data = data,
enabled = false,
)
HorizontalDivider()
CheckableUserRow(
checked = true,
onCheckedChange = { },
data = data,
enabled = false,
)
}
}
@Preview
@Composable
internal fun CheckableUnresolvedUserRowPreview() = ElementThemedPreview {
val matrixUser = aMatrixUser()
val data = CheckableUserRowData.Unresolved(
avatarData = matrixUser.getAvatarData(AvatarSize.UserListItem),
id = matrixUser.userId.value,
)
Column {
CheckableUserRow(
checked = false,
onCheckedChange = { },
data = data,
)
HorizontalDivider()
CheckableUserRow(
checked = true,
onCheckedChange = { },
data = data,
)
HorizontalDivider()
CheckableUserRow(
checked = false,
onCheckedChange = { },
data = data,
enabled = false,
)
HorizontalDivider()
CheckableUserRow(
checked = true,
onCheckedChange = { },
data = data,
enabled = false,
)
}
}
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.R
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getFullName
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Figma:
* https://www.figma.com/design/dywzKQvHYxFD1Ncn4a5NkI/PSB-675%253A-Improve-invite-into-a-DM?node-id=12-36886
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateDmConfirmationBottomSheet(
matrixUser: MatrixUser,
onSendInvite: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.screen_bottom_sheet_create_dm_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onSendInvite,
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
text = stringResource(CommonStrings.action_cancel),
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@PreviewsDayNight
@Composable
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
CreateDmConfirmationBottomSheet(
matrixUser = matrixUser,
onSendInvite = {},
onDismiss = {},
)
}
@@ -0,0 +1,137 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun EditableAvatarView(
matrixId: String,
displayName: String?,
avatarUrl: String?,
avatarSize: AvatarSize,
avatarType: AvatarType,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val a11yAvatar = stringResource(CommonStrings.a11y_avatar)
Box(
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
onClickLabel = stringResource(CommonStrings.a11y_edit_avatar),
onClick = onAvatarClick,
indication = ripple(bounded = false),
)
.testTag(TestTags.editAvatar)
.clearAndSetSemantics {
contentDescription = a11yAvatar
},
) {
when {
avatarUrl == null || avatarUrl.startsWith("mxc://") -> {
Avatar(
avatarData = AvatarData(
id = matrixId,
name = displayName,
url = avatarUrl,
size = avatarSize,
),
avatarType = avatarType,
)
}
else -> {
UnsavedAvatar(
avatarUri = avatarUrl,
avatarSize = avatarSize,
avatarType = avatarType,
)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(ElementTheme.colors.iconPrimary)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.EditSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun EditableAvatarViewPreview(
@PreviewParameter(EditableAvatarViewUriProvider::class) uri: String?
) = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) {
EditableAvatarView(
matrixId = "id",
displayName = "A room",
avatarUrl = uri,
avatarSize = AvatarSize.EditRoomDetails,
avatarType = AvatarType.User,
onAvatarClick = {},
)
}
open class EditableAvatarViewUriProvider : PreviewParameterProvider<String?> {
override val values: Sequence<String?>
get() = sequenceOf(
null,
"mxc://matrix.org/123456",
"https://example.com/avatar.jpg",
)
}
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.unit.LayoutDirection
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.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.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2678&m=dev
*/
@Composable
fun EditableOrgAvatar(
avatarData: AvatarData,
onEdit: () -> Unit,
modifier: Modifier = Modifier,
) {
val actionEdit = stringResource(id = CommonStrings.action_edit)
val description = stringResource(CommonStrings.a11y_avatar)
Box(
modifier = modifier
.width(avatarData.size.dp + 16.dp)
.clearAndSetSemantics {
contentDescription = description
// Note: this does not set the click effect to the whole Box
// when talkback is not enabled
onClick(
label = actionEdit,
action = {
onEdit()
true
}
)
}
) {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val editIconRadius = 17.dp.toPx()
val editIconXOffset = 7.dp.toPx()
val editIconYOffset = 15.dp.toPx()
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space(false),
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
val xOffset = if (isRtl) {
editIconXOffset
} else {
size.width - editIconXOffset
}
drawCircle(
color = Color.Black,
center = Offset(
x = xOffset,
y = size.height - editIconYOffset,
),
radius = editIconRadius,
blendMode = BlendMode.Clear,
)
},
)
Surface(
color = ElementTheme.colors.bgCanvasDefault,
shape = CircleShape,
border = BorderStroke(1.dp, color = ElementTheme.colors.borderInteractiveSecondary),
modifier = Modifier
.clip(CircleShape)
.size(30.dp)
.align(Alignment.BottomEnd)
.clickable(
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
onClick = onEdit,
),
) {
Icon(
imageVector = CompoundIcons.Edit(),
// Note: keep the context description for the test
contentDescription = stringResource(id = CommonStrings.action_edit),
tint = ElementTheme.colors.iconPrimary,
modifier = Modifier.padding(6.dp)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun EditableOrgAvatarPreview() = ElementPreview {
EditableOrgAvatar(
avatarData = anAvatarData(
url = "anUrl",
size = AvatarSize.OrganizationHeader,
),
onEdit = {},
)
}
@PreviewsDayNight
@Composable
internal fun EditableOrgAvatarRtlPreview() = CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview {
EditableOrgAvatar(
avatarData = anAvatarData(
url = "anUrl",
size = AvatarSize.OrganizationHeader,
),
onEdit = {},
)
}
}
@@ -0,0 +1,70 @@
/*
* 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.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.InviteSender
@Composable
fun InviteSenderView(
inviteSender: InviteSender,
modifier: Modifier = Modifier,
hideAvatarImage: Boolean = false,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Box(modifier = Modifier.padding(vertical = 2.dp)) {
Avatar(
avatarData = inviteSender.avatarData,
avatarType = AvatarType.User,
hideImage = hideAvatarImage,
)
}
Text(
text = inviteSender.annotatedString(),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
@PreviewsDayNight
@Composable
internal fun InviteSenderViewPreview() = ElementPreview {
InviteSenderView(
inviteSender = InviteSender(
userId = UserId("@bob:example.com"),
displayName = "Bob",
avatarData = AvatarData(
id = "@bob:example.com",
name = "Bob",
url = null,
size = AvatarSize.InviteSender,
),
membershipChangeReason = null,
)
)
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun JoinButton(
showProgress: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textActionAccent) {
TextButton(
modifier = modifier,
text = stringResource(CommonStrings.action_join),
onClick = onClick,
size = ButtonSize.LargeLowPadding,
showProgress = showProgress,
)
}
}
@@ -0,0 +1,103 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
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.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun MatrixUserHeader(
matrixUser: MatrixUser?,
modifier: Modifier = Modifier,
// TODO handle click on this item, to let the user be able to update their profile.
// onClick: () -> Unit,
) {
if (matrixUser == null) {
MatrixUserHeaderPlaceholder(modifier = modifier)
} else {
MatrixUserHeaderContent(
matrixUser = matrixUser,
modifier = modifier,
// onClick = onClick
)
}
}
@Composable
private fun MatrixUserHeaderContent(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
// onClick: () -> Unit,
) {
Row(
modifier = modifier
// .clickable(onClick = onClick)
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
modifier = Modifier
.padding(vertical = 12.dp),
avatarData = matrixUser.getAvatarData(size = AvatarSize.UserPreference),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
// Name
Text(
modifier = Modifier.clipToBounds(),
text = matrixUser.getBestName(),
maxLines = 1,
style = ElementTheme.typography.fontHeadingSmMedium,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
)
// Id
if (matrixUser.displayName.isNullOrEmpty().not()) {
Text(
text = matrixUser.userId.value,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun MatrixUserHeaderPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
MatrixUserHeader(matrixUser)
}
@@ -0,0 +1,64 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.placeholderBackground
@Composable
fun MatrixUserHeaderPlaceholder(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.padding(vertical = 12.dp)
.size(AvatarSize.UserPreference.dp)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
PlaceholderAtom(width = 80.dp, height = 7.dp)
Spacer(modifier = Modifier.height(16.dp))
PlaceholderAtom(width = 180.dp, height = 6.dp)
}
}
}
@PreviewsDayNight
@Composable
internal fun MatrixUserHeaderPlaceholderPreview() = ElementPreview {
MatrixUserHeaderPlaceholder()
}
@@ -0,0 +1,61 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
override val values: Sequence<MatrixUser>
get() = sequenceOf(
aMatrixUser(),
aMatrixUser(displayName = null),
)
}
open class MatrixUserWithNullProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(
aMatrixUser(),
aMatrixUser(displayName = null),
null,
)
}
open class MatrixUserWithAvatarProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(
aMatrixUser(displayName = "John Doe"),
aMatrixUser(displayName = "John Doe", avatarUrl = "anUrl"),
)
}
fun aMatrixUser(
id: String = "@id_of_alice:server.org",
displayName: String? = "Alice",
avatarUrl: String? = null,
) = MatrixUser(
userId = UserId(id),
displayName = displayName,
avatarUrl = avatarUrl,
)
fun aMatrixUserList() = listOf(
aMatrixUser("@alice:server.org", "Alice"),
aMatrixUser("@bob:server.org", "Bob"),
aMatrixUser("@carol:server.org", "Carol"),
aMatrixUser("@david:server.org", "David"),
aMatrixUser("@eve:server.org", "Eve"),
aMatrixUser("@justin:server.org", "Justin"),
aMatrixUser("@mallory:server.org", "Mallory"),
aMatrixUser("@susie:server.org", "Susie"),
aMatrixUser("@victor:server.org", "Victor"),
aMatrixUser("@walter:server.org", "Walter"),
)
@@ -0,0 +1,39 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.UserListItem,
trailingContent: @Composable (() -> Unit)? = null,
) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
trailingContent = trailingContent,
)
@PreviewsDayNight
@Composable
internal fun MatrixUserRowPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
MatrixUserRow(matrixUser)
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.unit.dp
import io.element.android.compound.theme.ElementTheme
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.avatar.anAvatarData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2048&m=dev
*/
@Composable
fun OrganizationHeader(
avatarData: AvatarData,
name: String,
numberOfSpaces: Int,
numberOfRooms: Int,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 24.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space(false),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = name,
style = ElementTheme.typography.fontHeadingLgBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
SpaceInfoRow(
leftText = numberOfSpaces(numberOfSpaces),
rightText = numberOfRooms(numberOfRooms),
)
}
}
@PreviewsDayNight
@Composable
internal fun OrganizationHeaderPreview() = ElementPreview {
OrganizationHeader(
avatarData = anAvatarData(
url = "anUrl",
size = AvatarSize.OrganizationHeader,
),
name = "Space name",
numberOfSpaces = 9,
numberOfRooms = 88,
)
}
@@ -0,0 +1,42 @@
/*
* 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.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
class SelectRoomInfoProvider : PreviewParameterProvider<SelectRoomInfo> {
override val values: Sequence<SelectRoomInfo>
get() = sequenceOf(
aSelectRoomInfo(roomId = RoomId("!room1:domain")),
aSelectRoomInfo(roomId = RoomId("!room2:domain"), name = "Room with a name"),
aSelectRoomInfo(roomId = RoomId("!room3:domain"), name = "Room with a name and avatar", avatarUrl = "anUrl"),
)
}
fun aSelectRoomInfo(
roomId: RoomId,
name: String? = null,
canonicalAlias: RoomAlias? = null,
avatarUrl: String? = null,
heroes: ImmutableList<MatrixUser> = persistentListOf(),
isTombstoned: Boolean = false,
) = SelectRoomInfo(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
avatarUrl = avatarUrl,
heroes = heroes,
isTombstoned = isTombstoned,
)
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
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.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SelectedItem(
avatarData: AvatarData,
avatarType: AvatarType,
text: String,
maxLines: Int,
a11yContentDescription: String,
canRemove: Boolean,
onRemoveClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val actionRemove = stringResource(id = CommonStrings.action_remove)
Box(
modifier = modifier
.width(avatarData.size.dp)
.clearAndSetSemantics {
contentDescription = a11yContentDescription
if (canRemove) {
// Note: this does not set the click effect to the whole Box
// when talkback is not enabled
onClick(
label = actionRemove,
action = {
onRemoveClick()
true
}
)
}
}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val closeIconRadius = 12.dp.toPx()
val closeIconOffset = 10.dp.toPx()
Avatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = Modifier
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
if (canRemove) {
val xOffset = if (isRtl) {
closeIconOffset
} else {
size.width - closeIconOffset
}
drawCircle(
color = Color.Black,
center = Offset(
x = xOffset,
y = closeIconOffset,
),
radius = closeIconRadius,
blendMode = BlendMode.Clear,
)
}
},
)
Text(
modifier = Modifier.clipToBounds(),
text = text,
overflow = TextOverflow.Ellipsis,
maxLines = maxLines,
style = MaterialTheme.typography.bodyMedium,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
if (canRemove) {
Surface(
color = ElementTheme.colors.bgActionPrimaryRest,
modifier = Modifier
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
.clickable(
indication = ripple(),
interactionSource = remember { MutableInteractionSource() },
onClick = onRemoveClick,
),
) {
Icon(
imageVector = CompoundIcons.Close(),
// Note: keep the context description for the test
contentDescription = stringResource(id = CommonStrings.action_remove),
tint = ElementTheme.colors.iconOnSolidPrimary,
modifier = Modifier.padding(2.dp)
)
}
}
}
}
@@ -0,0 +1,75 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@Composable
fun SelectedRoom(
roomInfo: SelectRoomInfo,
onRemoveRoom: (SelectRoomInfo) -> Unit,
modifier: Modifier = Modifier,
) {
SelectedItem(
avatarData = roomInfo.getAvatarData(AvatarSize.SelectedRoom),
avatarType = AvatarType.Room(
heroes = roomInfo.heroes.map { it.getAvatarData(AvatarSize.SelectedRoom) }.toImmutableList(),
isTombstoned = roomInfo.isTombstoned,
),
// If name is null, we do not have space to render "No room name", so just use `#` here.
text = roomInfo.name ?: "#",
maxLines = 1,
a11yContentDescription = roomInfo.name
?: roomInfo.canonicalAlias?.value
?: stringResource(id = CommonStrings.common_room_name),
canRemove = true,
onRemoveClick = { onRemoveRoom(roomInfo) },
modifier = modifier,
)
}
@PreviewsDayNight
@Composable
internal fun SelectedRoomPreview(
@PreviewParameter(SelectRoomInfoProvider::class) roomInfo: SelectRoomInfo
) = ElementPreview {
SelectedRoom(
roomInfo = roomInfo,
onRemoveRoom = {},
)
}
@PreviewsDayNight
@Composable
internal fun SelectedRoomRtlPreview(
@PreviewParameter(SelectRoomInfoProvider::class) roomInfo: SelectRoomInfo
) = CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview {
SelectedRoom(
roomInfo = roomInfo,
onRemoveRoom = {},
)
}
}
@@ -0,0 +1,76 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.LayoutDirection
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun SelectedUser(
matrixUser: MatrixUser,
canRemove: Boolean,
onUserRemove: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
) {
SelectedItem(
avatarData = matrixUser.getAvatarData(size = AvatarSize.SelectedUser),
avatarType = AvatarType.User,
text = matrixUser.getBestName(),
maxLines = 2,
a11yContentDescription = matrixUser.getBestName(),
canRemove = canRemove,
onRemoveClick = { onUserRemove(matrixUser) },
modifier = modifier,
)
}
@PreviewsDayNight
@Composable
internal fun SelectedUserPreview(@PreviewParameter(MatrixUserWithAvatarProvider::class) user: MatrixUser) = ElementPreview {
SelectedUser(
matrixUser = user,
canRemove = true,
onUserRemove = {},
)
}
@PreviewsDayNight
@Composable
internal fun SelectedUserRtlPreview() = CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview {
SelectedUser(
matrixUser = aMatrixUser(displayName = "John Doe"),
canRemove = true,
onUserRemove = {},
)
}
}
@PreviewsDayNight
@Composable
internal fun SelectedUserCannotRemovePreview() = ElementPreview {
SelectedUser(
matrixUser = aMatrixUser(),
canRemove = false,
onUserRemove = {},
)
}
@@ -0,0 +1,149 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlin.math.floor
@Composable
fun SelectedUsersRowList(
selectedUsers: ImmutableList<MatrixUser>,
onUserRemove: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
autoScroll: Boolean = false,
canDeselect: (MatrixUser) -> Boolean = { true },
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val lazyListState = rememberLazyListState()
if (autoScroll) {
var currentSize by rememberSaveable { mutableIntStateOf(selectedUsers.size) }
LaunchedEffect(selectedUsers.size) {
val isItemAdded = selectedUsers.size > currentSize
if (isItemAdded) {
lazyListState.animateScrollToItem(selectedUsers.lastIndex)
}
currentSize = selectedUsers.size
}
}
val rowWidth by remember {
derivedStateOf {
lazyListState.layoutInfo.viewportSize.width - lazyListState.layoutInfo.beforeContentPadding
}
}
// Calculate spacing to show between each user. This is at least [minimumSpacing], and will grow to ensure that if the available space is filled with
// users, the last visible user will be precisely half visible. This gives an obvious affordance that there are more entries and the list can be scrolled.
// For efficiency, we assume that all the children are the same width. If they needed to be different sizes we'd have to do this calculation each time
// they needed to be measured.
val minimumSpacing = 24.dp.toPx()
val userWidth = 56.dp.toPx()
val userSpacing by remember {
derivedStateOf {
if (rowWidth == 0) {
// The row hasn't yet been measured yet, so we don't know how big it is
minimumSpacing
} else {
val userWidthWithSpacing = userWidth + minimumSpacing
val maxVisibleUsers = rowWidth / userWidthWithSpacing
// Round down the number of visible users to end with a state where one is half visible
val targetFraction = userWidth / 2 / userWidthWithSpacing
val targetUsers = floor(maxVisibleUsers - targetFraction) + targetFraction
// Work out how much extra spacing we need to reduce the number of users that much, then split it evenly amongst the visible users
val extraSpacing = (maxVisibleUsers - targetUsers) * userWidthWithSpacing
val extraSpacingPerUser = extraSpacing / floor(targetUsers)
minimumSpacing + extraSpacingPerUser
}
}
}
LazyRow(
state = lazyListState,
modifier = modifier
.fillMaxWidth(),
contentPadding = contentPadding,
) {
itemsIndexed(selectedUsers.toList()) { index, selectedUser ->
Layout(
content = {
SelectedUser(
matrixUser = selectedUser,
canRemove = canDeselect(selectedUser),
onUserRemove = onUserRemove,
)
},
measurePolicy = { measurables, constraints ->
val placeable = measurables.first().measure(constraints)
val spacing = if (index == selectedUsers.lastIndex) 0f else userSpacing
layout(
width = (placeable.width + spacing).toInt(),
height = placeable.height
) {
placeable.place(0, 0)
}
}
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SelectedUsersRowListPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Two users that will be visible with no scrolling
SelectedUsersRowList(
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
onUserRemove = {},
modifier = Modifier
.width(200.dp)
.border(1.dp, Color.Red)
)
// Multiple users that don't fit, so will be spaced out per the measure policy
for (i in 0..5) {
SelectedUsersRowList(
selectedUsers = aMatrixUserList().take(6).toImmutableList(),
onUserRemove = {},
modifier = Modifier
.width((200 + i * 20).dp)
.border(1.dp, Color.Red)
)
}
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2048
*/
@Composable
fun SpaceHeaderRootView(
numberOfSpaces: Int,
modifier: Modifier = Modifier,
) {
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)
) {
BigIcon(
style = BigIcon.Style.Default(CompoundIcons.WorkspaceSolid())
)
Text(
text = stringResource(CommonStrings.screen_space_list_title),
style = ElementTheme.typography.fontHeadingLgBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
SpaceInfoRow(
leftText = numberOfSpaces(numberOfSpaces),
rightText = null,
)
Text(
text = stringResource(CommonStrings.screen_space_list_description),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
}
}
@PreviewsDayNight
@Composable
internal fun SpaceHeaderRootViewPreview() = ElementPreview {
SpaceHeaderRootView(
numberOfSpaces = 3,
)
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
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.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
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.spaces.SpaceRoomVisibility
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2429&m=dev
*/
@Composable
fun SpaceHeaderView(
avatarData: AvatarData,
name: String?,
topic: String?,
visibility: SpaceRoomVisibility,
heroes: ImmutableList<MatrixUser>,
numberOfMembers: Int,
modifier: Modifier = Modifier,
topicMaxLines: Int = Int.MAX_VALUE,
onTopicClick: ((String) -> Unit)? = null,
) {
RoomPreviewOrganism(
modifier = modifier.padding(24.dp),
avatar = {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space(),
)
},
title = {
if (name != null) {
RoomPreviewTitleAtom(title = name)
} else {
RoomPreviewTitleAtom(
title = stringResource(id = CommonStrings.common_no_space_name),
fontStyle = FontStyle.Italic
)
}
},
subtitle = {
SpaceInfoRow(visibility = visibility)
},
description = if (topic.isNullOrBlank()) {
null
} else {
{
RoomPreviewDescriptionAtom(
description = topic,
maxLines = topicMaxLines,
modifier = Modifier.clickable(
enabled = onTopicClick != null,
onClick = { onTopicClick?.invoke(topic) }
)
)
}
},
memberCount = {
SpaceMembersView(
heroes = heroes,
numberOfMembers = numberOfMembers,
modifier = Modifier.padding(horizontal = 32.dp),
)
},
)
}
@PreviewsDayNight
@Composable
internal fun SpaceHeaderViewPreview() = ElementPreview {
SpaceHeaderView(
avatarData = anAvatarData(
url = "anUrl",
size = AvatarSize.SpaceHeader,
),
name = "Space name",
topic = "Space topic: " + LoremIpsum(40).values.first(),
topicMaxLines = 2,
visibility = SpaceRoomVisibility.Public,
heroes = persistentListOf(
aMatrixUser(id = "@1:d", displayName = "Alice", avatarUrl = "aUrl"),
aMatrixUser(id = "@2:d", displayName = "Bob"),
aMatrixUser(id = "@3:d", displayName = "Charlie", avatarUrl = "aUrl"),
aMatrixUser(id = "@4:d", displayName = "Dave"),
),
numberOfMembers = 999,
)
}
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
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.TextAlign
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.theme.components.Icon
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
import io.element.android.libraries.matrix.ui.model.icon
import io.element.android.libraries.matrix.ui.model.label
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SpaceInfoRow(
leftText: String,
rightText: String?,
modifier: Modifier = Modifier,
iconVector: ImageVector? = null,
) {
Row(
modifier = modifier,
horizontalArrangement = spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (iconVector != null) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = iconVector,
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
val text = if (rightText != null) {
stringResource(id = CommonStrings.screen_space_list_details, leftText, rightText)
} else {
leftText
}
Text(
text = text,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
}
@Composable
fun SpaceInfoRow(
visibility: SpaceRoomVisibility,
modifier: Modifier = Modifier,
) {
SpaceInfoRow(
leftText = visibility.label,
rightText = null,
modifier = modifier,
iconVector = visibility.icon,
)
}
@Composable
@ReadOnlyComposable
fun numberOfRooms(numberOfRooms: Int): String {
return pluralStringResource(CommonPlurals.common_rooms, numberOfRooms, numberOfRooms)
}
@Composable
@ReadOnlyComposable
fun numberOfSpaces(numberOfSpaces: Int): String {
return pluralStringResource(CommonPlurals.common_spaces, numberOfSpaces, numberOfSpaces)
}
@PreviewsDayNight
@Composable
internal fun SpaceInfoRowPreview() = ElementPreview {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SpaceInfoRow(
leftText = numberOfSpaces(5),
rightText = numberOfRooms(10),
)
SpaceInfoRow(
leftText = "Element space",
rightText = numberOfRooms(16),
iconVector = CompoundIcons.Workspace(),
)
SpaceInfoRow(
visibility = SpaceRoomVisibility.Private,
)
SpaceInfoRow(
visibility = SpaceRoomVisibility.Public
)
SpaceInfoRow(
visibility = SpaceRoomVisibility.Restricted
)
}
}
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.molecules.MembersCountMolecule
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarRow
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3729-605&m=dev
*/
@Composable
fun SpaceMembersView(
heroes: ImmutableList<MatrixUser>,
numberOfMembers: Int,
modifier: Modifier = Modifier,
) {
if (heroes.isEmpty()) {
MembersCountMolecule(
memberCount = numberOfMembers,
modifier = modifier,
)
} else {
SpaceMembersWithAvatar(
heroes = heroes
.take(3)
.map {
it.getAvatarData(AvatarSize.SpaceMember)
}
.toImmutableList(),
numberOfMembers = numberOfMembers,
modifier = modifier,
)
}
}
@Composable
private fun SpaceMembersWithAvatar(
heroes: ImmutableList<AvatarData>,
numberOfMembers: Int,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AvatarRow(
avatarDataList = heroes,
avatarType = AvatarType.User,
lastOnTop = true,
)
Text(
text = "$numberOfMembers",
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
@Composable
@PreviewsDayNight
internal fun SpaceMembersViewNoHeroesPreview() = ElementPreview {
SpaceMembersView(
heroes = persistentListOf(),
numberOfMembers = 123,
)
}
@Composable
@PreviewsDayNight
internal fun SpaceMembersViewPreview() = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) {
SpaceMembersView(
heroes = persistentListOf(
aMatrixUser(id = "@1:d", displayName = "Alice", avatarUrl = "aUrl"),
aMatrixUser(id = "@2:d", displayName = "Bob"),
aMatrixUser(id = "@3:d", displayName = "Charlie", avatarUrl = "aUrl"),
aMatrixUser(id = "@4:d", displayName = "Dave"),
),
numberOfMembers = 123,
)
}
@@ -0,0 +1,278 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.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
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
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.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.icon
import io.element.android.libraries.matrix.ui.model.label
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Figma reference: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=3643-2079&m=dev
*/
@Composable
fun SpaceRoomItemView(
spaceRoom: SpaceRoom,
showUnreadIndicator: Boolean,
hideAvatars: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
trailingAction: @Composable (() -> Unit)? = null,
bottomAction: @Composable (() -> Unit)? = null,
) {
val clickModifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
indication = ripple(),
interactionSource = remember { MutableInteractionSource() }
)
.onKeyboardContextMenuAction { onLongClick }
Column(
modifier = modifier
.then(clickModifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
SpaceRoomItemScaffold(
avatarData = spaceRoom.getAvatarData(AvatarSize.SpaceListItem),
isSpace = spaceRoom.isSpace,
hideAvatars = hideAvatars,
heroes = spaceRoom.heroes
.map { hero -> hero.getAvatarData(AvatarSize.SpaceListItem) }
.toImmutableList(),
trailingAction = trailingAction,
) {
NameAndIndicatorRow(
name = spaceRoom.displayName,
showIndicator = showUnreadIndicator
)
Spacer(modifier = Modifier.height(1.dp))
SubtitleRow(
visibilityIcon = spaceRoom.visibilityIcon(),
subtitle = spaceRoom.subtitle()
)
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
)
}
}
if (bottomAction != null) {
Spacer(modifier = Modifier.height(12.dp))
// Match the padding of the text content (avatar + spacer)
Box(modifier = Modifier.padding(start = AvatarSize.SpaceListItem.dp + 16.dp)) {
bottomAction()
}
Spacer(modifier = Modifier.height(4.dp))
}
}
}
@Composable
private fun SubtitleRow(
visibilityIcon: ImageVector?,
subtitle: String,
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,
)
}
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = subtitle,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun NameAndIndicatorRow(
name: String,
showIndicator: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (showIndicator) {
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator
)
}
}
}
@Composable
private fun SpaceRoomItemScaffold(
avatarData: AvatarData,
isSpace: Boolean,
heroes: ImmutableList<AvatarData>,
hideAvatars: Boolean,
modifier: Modifier = Modifier,
trailingAction: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = avatarData,
avatarType = if (isSpace) AvatarType.Space() else AvatarType.Room(heroes = heroes),
hideImage = hideAvatars,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
content = content,
)
if (trailingAction != null) {
Spacer(modifier = Modifier.width(16.dp))
trailingAction()
}
}
}
@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 {
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = spaceRoom.state == CurrentUserMembership.INVITED,
hideAvatars = false,
onClick = {},
onLongClick = {},
bottomAction = if (spaceRoom.state == CurrentUserMembership.INVITED) {
{ InviteButtonsRowMolecule({}, {}) }
} else {
null
},
trailingAction = when (spaceRoom.state) {
null, CurrentUserMembership.LEFT -> {
{
JoinButton(
showProgress = spaceRoom.state == CurrentUserMembership.LEFT,
onClick = { },
)
}
}
else -> null
}
)
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.RoomType
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
override val values: Sequence<SpaceRoom> = sequenceOf(
aSpaceRoom(
roomType = RoomType.Room,
displayName = "Room name with topic",
topic = "Room topic that is quite long and might be truncated"
),
aSpaceRoom(
roomType = RoomType.Room,
displayName = "Room name no topic",
state = CurrentUserMembership.LEFT,
),
aSpaceRoom(
displayName = "Alice",
roomType = RoomType.Room,
isDirect = true,
heroes = listOf(aMatrixUser(displayName = "Alice")),
state = CurrentUserMembership.JOINED,
numJoinedMembers = 2,
),
aSpaceRoom(
roomType = RoomType.Room,
displayName = "Room name with topic",
topic = "Room topic that is quite long and might be truncated",
state = CurrentUserMembership.INVITED,
),
aSpaceRoom(
roomType = RoomType.Room,
displayName = "Room name no topic",
state = CurrentUserMembership.INVITED,
),
aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
roomId = RoomId("!spaceId0:example.com"),
),
aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
avatarUrl = "anUrl",
roomId = RoomId("!spaceId1:example.com"),
state = CurrentUserMembership.LEFT,
),
aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
avatarUrl = "anUrl",
roomId = RoomId("!spaceId2:example.com"),
state = CurrentUserMembership.INVITED,
),
aSpaceRoom(
displayName = "Alice",
roomType = RoomType.Space,
heroes = listOf(aMatrixUser(displayName = "Alice")),
state = CurrentUserMembership.JOINED,
numJoinedMembers = 2,
),
)
}
@@ -0,0 +1,103 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
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.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun UnresolvedUserRow(
avatarData: AvatarData,
id: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Column(
modifier = Modifier
.padding(start = 12.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
// ID
Text(
text = id,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled,
style = ElementTheme.typography.fontBodyLgMedium,
)
// Warning
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 3.dp)
) {
Icon(
imageVector = CompoundIcons.ErrorSolid(),
contentDescription = null,
modifier = Modifier
.size(18.dp)
.align(Alignment.Top)
.padding(2.dp),
tint = if (enabled) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconDisabled,
)
Text(
text = stringResource(CommonStrings.common_invite_unknown_profile),
color = if (enabled) ElementTheme.colors.textSecondary else ElementTheme.colors.textDisabled,
style = ElementTheme.typography.fontBodySmRegular.copy(lineHeight = 16.sp),
)
}
}
}
}
@Preview
@Composable
internal fun UnresolvedUserRowPreview() = ElementThemedPreview {
val matrixUser = aMatrixUser()
Column {
UnresolvedUserRow(matrixUser.getAvatarData(size = AvatarSize.UserListItem), matrixUser.userId.value)
UnresolvedUserRow(matrixUser.getAvatarData(size = AvatarSize.UserListItem), matrixUser.userId.value, enabled = false)
}
}
@@ -0,0 +1,93 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import io.element.android.compound.theme.ElementTheme
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.avatarShape
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
/**
* An avatar that the user has selected, but which has not yet been uploaded to Matrix.
*
* The image is loaded from a local resource instead of from a MXC URI.
*/
@Composable
fun UnsavedAvatar(
avatarUri: String?,
avatarSize: AvatarSize,
avatarType: AvatarType,
modifier: Modifier = Modifier,
) {
val commonModifier = modifier
.size(avatarSize.dp)
.clip(avatarType.avatarShape(avatarSize.dp))
if (avatarUri != null) {
val context = LocalContext.current
val model = ImageRequest.Builder(context)
.data(avatarUri)
.build()
AsyncImage(
modifier = commonModifier,
model = model,
placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant),
contentScale = ContentScale.Crop,
contentDescription = null,
)
} else {
Box(modifier = commonModifier.background(ElementTheme.colors.temporaryColorBgSpecial)) {
Icon(
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = null,
modifier = Modifier
.align(Alignment.Center)
.size(avatarSize.dp * 4 / 7),
tint = ElementTheme.colors.iconSecondary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun UnsavedAvatarPreview() = ElementPreview {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.User)
UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.User)
UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.Space())
UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.Space())
}
}
@@ -0,0 +1,75 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
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.AvatarType
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
internal fun UserRow(
avatarData: AvatarData,
name: String,
subtext: String?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
trailingContent: @Composable (() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Column(
modifier = Modifier
.padding(start = 12.dp)
.weight(1f),
) {
// Name
Text(
modifier = Modifier.clipToBounds(),
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled,
style = ElementTheme.typography.fontBodyLgRegular,
)
// Id
subtext?.let {
Text(
text = subtext,
color = if (enabled) ElementTheme.colors.textSecondary else ElementTheme.colors.textDisabled,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
trailingContent?.invoke()
}
}
@@ -0,0 +1,38 @@
/*
* 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.libraries.matrix.ui.media
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
sealed class AvatarAction(
@StringRes val titleResId: Int,
@DrawableRes val iconResourceId: Int,
val destructive: Boolean = false,
) {
data object TakePhoto : AvatarAction(
titleResId = CommonStrings.action_take_photo,
iconResourceId = CompoundDrawables.ic_compound_take_photo,
)
data object ChoosePhoto : AvatarAction(
titleResId = CommonStrings.action_choose_photo,
iconResourceId = CompoundDrawables.ic_compound_image,
)
data object Remove : AvatarAction(
titleResId = CommonStrings.action_remove,
iconResourceId = CompoundDrawables.ic_compound_delete,
destructive = true
)
}
@@ -0,0 +1,34 @@
/*
* 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.matrix.ui.messages
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.runningFold
@SingleIn(RoomScope::class)
@Inject
class RoomMemberProfilesCache {
private val cache = MutableStateFlow(mapOf<UserId, RoomMember>())
val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 }
suspend fun replace(items: List<RoomMember>) = coroutineScope {
cache.value = items.associateBy { it.userId }
}
fun getDisplayName(userId: UserId): String? {
return cache.value[userId]?.disambiguatedDisplayName
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.messages
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.runningFold
@SingleIn(RoomScope::class)
@Inject
class RoomNamesCache {
private val cache = MutableStateFlow(mapOf<RoomIdOrAlias, String?>())
val updateFlow = cache.drop(1).runningFold(0) { acc, _ -> acc + 1 }
suspend fun replace(items: List<RoomSummary>) = coroutineScope {
val roomNamesByRoomIdOrAlias = LinkedHashMap<RoomIdOrAlias, String?>(items.size * 2)
items
.forEach { summary ->
roomNamesByRoomIdOrAlias[summary.info.id.toRoomIdOrAlias()] = summary.info.name
val canonicalAlias = summary.info.canonicalAlias
if (canonicalAlias != null) {
roomNamesByRoomIdOrAlias[canonicalAlias.toRoomIdOrAlias()] = summary.info.name
}
}
cache.value = roomNamesByRoomIdOrAlias
}
fun getDisplayName(roomIdOrAlias: RoomIdOrAlias): String? {
return cache.value[roomIdOrAlias]
}
}
@@ -0,0 +1,62 @@
/*
* 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.libraries.matrix.ui.messages
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
/**
* Converts the HTML string [FormattedBody.body] to a [Document] by parsing it.
* If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
*
* This will also make sure mentions are prefixed with `@`.
*
* @param permalinkParser the parser to use to parse the mentions.
* @param prefix if not null, the prefix will be inserted at the beginning of the message.
*/
fun FormattedBody.toHtmlDocument(
permalinkParser: PermalinkParser,
prefix: String? = null,
): Document? {
return takeIf { it.format == MessageFormat.HTML }?.body
// Trim whitespace at the end to avoid having wrong rendering of the message.
// We don't trim the start in case it's used as indentation.
?.trimEnd()
?.let { formattedBody ->
val dom = if (prefix != null) {
Jsoup.parse("$prefix $formattedBody")
} else {
Jsoup.parse(formattedBody)
}
// Prepend `@` to mentions
fixMentions(dom, permalinkParser)
dom
}
}
private fun fixMentions(
dom: Document,
permalinkParser: PermalinkParser,
) {
val links = dom.getElementsByTag("a")
links.forEach {
if (it.hasAttr("href")) {
val link = permalinkParser.parse(it.attr("href"))
if (link is PermalinkData.UserLink && !it.text().startsWith("@")) {
it.prependText("@")
}
}
}
}
@@ -0,0 +1,90 @@
/*
* 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.libraries.matrix.ui.messages
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.jsoup.select.NodeVisitor
/**
* Converts the HTML string in [TextMessageType.formatted] to a plain text representation by parsing it and removing all formatting.
* If the message is not formatted or the format is not [MessageFormat.HTML], the [TextMessageType.body] is returned instead.
*/
fun TextMessageType.toPlainText(
permalinkParser: PermalinkParser,
) = formatted?.toPlainText(permalinkParser) ?: body
/**
* Converts the HTML string in [FormattedBody.body] to a plain text representation by parsing it and removing all formatting.
* If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
* @param permalinkParser the parser to use to parse the mentions.
* @param prefix if not null, the prefix will be inserted at the beginning of the message.
*/
fun FormattedBody.toPlainText(
permalinkParser: PermalinkParser,
prefix: String? = null,
): String? {
return this.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = prefix,
)?.toPlainText()
}
/**
* Converts the HTML [Document] to a plain text representation by parsing it and removing all formatting.
*/
fun Document.toPlainText(): String {
val visitor = PlainTextNodeVisitor()
traverse(visitor)
return visitor.build()
}
private class PlainTextNodeVisitor : NodeVisitor {
private val builder = StringBuilder()
override fun head(node: Node, depth: Int) {
if (node is TextNode && node.text().isNotBlank()) {
builder.append(node.text())
} else if (node is Element && node.tagName() == "li") {
val index = node.elementSiblingIndex() + 1
val isOrdered = node.parent()?.nodeName()?.lowercase() == "ol"
if (isOrdered) {
val startIndex = node.parent()?.attr("start")?.toIntOrNull()
val actualIndex = if (startIndex != null) {
startIndex + index - 1
} else {
index
}
builder.append("$actualIndex. ")
} else {
builder.append("")
}
} else if (node is Element && node.isBlock && builder.lastOrNull() != '\n') {
builder.append("\n")
}
}
override fun tail(node: Node, depth: Int) {
fun nodeIsBlockButNotLastOne(node: Node) = node is Element && node.isBlock && node.lastElementSibling() !== node
fun nodeIsLineBreak(node: Node) = node.nodeName().lowercase() == "br"
if (nodeIsBlockButNotLastOne(node) || nodeIsLineBreak(node)) {
builder.append("\n")
}
}
fun build(): String {
return builder.toString().trim()
}
}
@@ -0,0 +1,66 @@
/*
* 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.matrix.ui.messages.reply
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.ui.messages.toPlainText
@Immutable
sealed interface InReplyToDetails {
data class Ready(
val eventId: EventId,
val senderId: UserId,
val senderProfile: ProfileDetails,
val eventContent: EventContent?,
val textContent: String?,
) : InReplyToDetails
data class Loading(val eventId: EventId) : InReplyToDetails
data class Error(val eventId: EventId, val message: String) : InReplyToDetails
}
fun InReplyToDetails.eventId() = when (this) {
is InReplyToDetails.Ready -> eventId
is InReplyToDetails.Loading -> eventId
is InReplyToDetails.Error -> eventId
}
fun InReplyTo.map(
permalinkParser: PermalinkParser,
) = when (this) {
is InReplyTo.Ready -> InReplyToDetails.Ready(
eventId = eventId,
senderId = senderId,
senderProfile = senderProfile,
eventContent = content,
textContent = when (content) {
is MessageContent -> {
val messageContent = content as MessageContent
(messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body
}
is StickerContent -> {
val stickerContent = content as StickerContent
stickerContent.body
}
else -> null
}
)
is InReplyTo.Error -> InReplyToDetails.Error(eventId, message)
is InReplyTo.NotLoaded -> InReplyToDetails.Loading(eventId)
is InReplyTo.Pending -> InReplyToDetails.Loading(eventId)
}
@@ -0,0 +1,168 @@
/*
* 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.matrix.ui.messages.reply
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
aMessageContent(
body = "Message which are being replied.",
type = TextMessageType("Message which are being replied.", null)
),
aMessageContent(
body = "Message which are being replied, and which was long enough to be displayed on two lines (only!).",
type = TextMessageType("Message which are being replied, and which was long enough to be displayed on two lines (only!).", null)
),
aMessageContent(
body = "Video",
type = VideoMessageType("Video", null, null, MediaSource("url"), null),
),
aMessageContent(
body = "Audio",
type = AudioMessageType("Audio", null, null, MediaSource("url"), null),
),
aMessageContent(
body = "Voice",
type = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null),
),
aMessageContent(
body = "Image",
type = ImageMessageType("Image", null, null, MediaSource("url"), null),
),
aMessageContent(
body = "Sticker",
type = StickerMessageType("Image", null, null, MediaSource("url"), null),
),
aMessageContent(
body = "File",
type = FileMessageType("File", null, null, MediaSource("url"), null),
),
aMessageContent(
body = "Location",
type = LocationMessageType("Location", "geo:1,2", null),
),
aMessageContent(
body = "Notice",
type = NoticeMessageType("Notice", null),
),
aMessageContent(
body = "Emote",
type = EmoteMessageType("Emote", null),
),
PollContent(
question = "Poll which are being replied.",
kind = PollKind.Disclosed,
maxSelections = 1u,
answers = persistentListOf(),
votes = persistentMapOf(),
endTime = null,
isEdited = false,
),
).map {
aInReplyToDetails(
eventContent = it,
)
}
}
class InReplyToDetailsDisambiguatedProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
aMessageContent(
body = "Message which are being replied.",
type = TextMessageType("Message which are being replied.", null)
),
).map {
aInReplyToDetails(
displayNameAmbiguous = true,
eventContent = it,
)
}
}
class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
RedactedContent,
UnableToDecryptContent(UnableToDecryptContent.Data.Unknown),
).map {
aInReplyToDetails(
eventContent = it,
)
}
}
class InReplyToDetailsOtherProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
InReplyToDetails.Loading(eventId = EventId("\$anEventId")),
InReplyToDetails.Error(eventId = EventId("\$anEventId"), message = "An error message."),
)
}
private fun aMessageContent(
body: String,
type: MessageType,
threadInfo: EventThreadInfo? = null,
) = MessageContent(
body = body,
inReplyTo = null,
isEdited = false,
threadInfo = threadInfo,
type = type,
)
private fun aInReplyToDetails(
eventContent: EventContent,
displayNameAmbiguous: Boolean = false,
) = InReplyToDetails.Ready(
eventId = EventId("\$event"),
eventContent = eventContent,
senderId = UserId("@Sender:domain"),
senderProfile = aProfileTimelineDetailsReady(
displayNameAmbiguous = displayNameAmbiguous,
),
textContent = (eventContent as? MessageContent)?.body.orEmpty(),
)
fun aProfileTimelineDetailsReady(
displayName: String? = "Sender",
displayNameAmbiguous: Boolean = false,
avatarUrl: String? = null,
) = ProfileDetails.Ready(
displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl,
)
@@ -0,0 +1,131 @@
/*
* 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.matrix.ui.messages.reply
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
internal sealed interface InReplyToMetadata {
data class Thumbnail(
val attachmentThumbnailInfo: AttachmentThumbnailInfo
) : InReplyToMetadata {
val text: String = attachmentThumbnailInfo.textContent.orEmpty()
}
data class Text(
val text: String
) : InReplyToMetadata
sealed interface Informative : InReplyToMetadata
data object Redacted : Informative
data object UnableToDecrypt : Informative
}
/**
* Computes metadata for the in reply to message.
*
* Metadata can be either a thumbnail with a text OR just a text.
*/
@Composable
internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetadata? = when (eventContent) {
is MessageContent -> when (val type = eventContent.type) {
is ImageMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = (type.info?.thumbnailSource ?: type.source).takeUnless { hideImage },
textContent = eventContent.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
)
is VideoMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage },
textContent = eventContent.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
)
is FileMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage },
textContent = eventContent.body,
type = AttachmentThumbnailType.File,
)
)
is LocationMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_shared_location),
type = AttachmentThumbnailType.Location,
)
)
is AudioMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.body,
type = AttachmentThumbnailType.Audio,
)
)
is VoiceMessageType -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = stringResource(CommonStrings.common_voice_message),
type = AttachmentThumbnailType.Voice,
)
)
else -> InReplyToMetadata.Text(textContent ?: eventContent.body)
}
is StickerContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
thumbnailSource = eventContent.source.takeUnless { hideImage },
textContent = eventContent.body,
type = AttachmentThumbnailType.Image,
blurHash = eventContent.info.blurhash,
)
)
is PollContent -> InReplyToMetadata.Thumbnail(
AttachmentThumbnailInfo(
textContent = eventContent.question,
type = AttachmentThumbnailType.Poll,
)
)
is RedactedContent -> InReplyToMetadata.Redacted
is UnableToDecryptContent -> InReplyToMetadata.UnableToDecrypt
is FailedToParseMessageLikeContent,
is FailedToParseStateContent,
is ProfileChangeContent,
is RoomMembershipContent,
is StateContent,
UnknownContent,
is LegacyCallInviteContent,
is CallNotifyContent,
null -> null
}
@@ -0,0 +1,214 @@
/*
* 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.matrix.ui.messages.reply
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
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.libraries.core.extensions.toSafeLength
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.messages.sender.SenderName
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun InReplyToView(
inReplyTo: InReplyToDetails,
hideImage: Boolean,
modifier: Modifier = Modifier,
) {
when (inReplyTo) {
is InReplyToDetails.Ready -> {
ReplyToReadyContent(
senderId = inReplyTo.senderId,
senderProfile = inReplyTo.senderProfile,
metadata = inReplyTo.metadata(hideImage),
modifier = modifier,
)
}
is InReplyToDetails.Error ->
ReplyToErrorContent(data = inReplyTo, modifier = modifier)
is InReplyToDetails.Loading ->
ReplyToLoadingContent(modifier = modifier)
}
}
@Composable
private fun ReplyToReadyContent(
senderId: UserId,
senderProfile: ProfileDetails,
metadata: InReplyToMetadata?,
modifier: Modifier = Modifier,
) {
val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
} else {
PaddingValues(horizontal = 12.dp, vertical = 4.dp)
}
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
if (metadata is InReplyToMetadata.Thumbnail) {
AttachmentThumbnail(
info = metadata.attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp))
)
Spacer(modifier = Modifier.width(8.dp))
}
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderProfile.getDisambiguatedDisplayName(senderId))
Column(
modifier = Modifier.semantics(mergeDescendants = false) { isTraversalGroup = true },
verticalArrangement = Arrangement.SpaceBetween
) {
SenderName(
senderId = senderId,
senderProfile = senderProfile,
senderNameMode = SenderNameMode.Reply,
modifier = Modifier.semantics {
contentDescription = a11InReplyToText
isTraversalGroup = true
traversalIndex = 1f
},
)
ReplyToContentText(metadata)
}
}
}
@Composable
private fun ReplyToLoadingContent(
modifier: Modifier = Modifier,
) {
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PlaceholderAtom(width = 80.dp, height = 12.dp)
PlaceholderAtom(width = 140.dp, height = 14.dp)
}
}
}
@Composable
private fun ReplyToErrorContent(
data: InReplyToDetails.Error,
modifier: Modifier = Modifier,
) {
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(MaterialTheme.colorScheme.surface)
.padding(paddings)
) {
Text(
text = data.message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textCriticalPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
val text = when (metadata) {
InReplyToMetadata.Redacted -> stringResource(id = CommonStrings.common_message_removed)
InReplyToMetadata.UnableToDecrypt -> stringResource(id = CommonStrings.common_waiting_for_decryption_key)
// Add a limit to the text length to avoid a crash in Compose
is InReplyToMetadata.Text -> metadata.text.toSafeLength()
// Add a limit to the text length to avoid a crash in Compose
is InReplyToMetadata.Thumbnail -> metadata.text.toSafeLength()
null -> ""
}
val iconResourceId = when (metadata) {
InReplyToMetadata.Redacted -> CompoundDrawables.ic_compound_delete
InReplyToMetadata.UnableToDecrypt -> CompoundDrawables.ic_compound_time
else -> null
}
val fontStyle = when (metadata) {
is InReplyToMetadata.Informative -> FontStyle.Italic
else -> FontStyle.Normal
}
Row(
modifier = Modifier.semantics(mergeDescendants = false) {
isTraversalGroup = true
traversalIndex = -1f
},
verticalAlignment = Alignment.CenterVertically,
) {
if (iconResourceId != null) {
Icon(
resourceId = iconResourceId,
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
fontStyle = fontStyle,
textAlign = TextAlign.Start,
color = ElementTheme.colors.textSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@PreviewsDayNight
@Composable
internal fun InReplyToViewPreview(@PreviewParameter(provider = InReplyToDetailsProvider::class) inReplyTo: InReplyToDetails) = ElementPreview {
InReplyToView(
inReplyTo = inReplyTo,
hideImage = false,
)
}
@@ -0,0 +1,127 @@
/*
* 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.matrix.ui.messages.sender
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
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.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
// https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0
@Composable
fun SenderName(
senderId: UserId,
senderProfile: ProfileDetails,
senderNameMode: SenderNameMode,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (senderProfile) {
is ProfileDetails.Error,
ProfileDetails.Pending,
ProfileDetails.Unavailable -> {
MainText(text = senderId.value, mode = senderNameMode)
}
is ProfileDetails.Ready -> {
val displayName = senderProfile.displayName
if (displayName.isNullOrEmpty()) {
MainText(text = senderId.value, mode = senderNameMode)
} else {
MainText(text = displayName, mode = senderNameMode)
if (senderProfile.displayNameAmbiguous) {
SecondaryText(text = senderId.value, mode = senderNameMode)
}
}
}
}
}
}
@Composable
private fun RowScope.MainText(
text: String,
mode: SenderNameMode,
) {
val style = when (mode) {
is SenderNameMode.Timeline -> ElementTheme.typography.fontBodyMdMedium
SenderNameMode.ActionList,
SenderNameMode.Reply -> ElementTheme.typography.fontBodySmMedium
}
val modifier = when (mode) {
is SenderNameMode.Timeline -> Modifier.alignByBaseline()
SenderNameMode.ActionList,
SenderNameMode.Reply -> Modifier
}
val color = when (mode) {
is SenderNameMode.Timeline -> mode.mainColor
SenderNameMode.ActionList,
SenderNameMode.Reply -> ElementTheme.colors.textPrimary
}
Text(
modifier = modifier.clipToBounds(),
text = text,
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun RowScope.SecondaryText(
text: String,
mode: SenderNameMode,
) {
val style = when (mode) {
is SenderNameMode.Timeline -> ElementTheme.typography.fontBodySmRegular
SenderNameMode.ActionList,
SenderNameMode.Reply -> ElementTheme.typography.fontBodyXsRegular
}
val modifier = when (mode) {
is SenderNameMode.Timeline -> Modifier.alignByBaseline()
SenderNameMode.ActionList,
SenderNameMode.Reply -> Modifier
}
Text(
modifier = modifier.clipToBounds(),
text = text,
style = style,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@PreviewsDayNight
@Composable
internal fun SenderNamePreview(
@PreviewParameter(SenderNameDataProvider::class) senderNameData: SenderNameData,
) = ElementPreview {
SenderName(
senderId = senderNameData.userId,
senderProfile = senderNameData.profileDetails,
senderNameMode = senderNameData.senderNameMode,
)
}
@@ -0,0 +1,58 @@
/*
* 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.matrix.ui.messages.sender
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
data class SenderNameData(
val userId: UserId,
val profileDetails: ProfileDetails,
val senderNameMode: SenderNameMode,
)
open class SenderNameDataProvider : PreviewParameterProvider<SenderNameData> {
override val values: Sequence<SenderNameData>
get() = sequenceOf(
SenderNameMode.Timeline(mainColor = Color.Red),
SenderNameMode.Reply,
SenderNameMode.ActionList,
)
.flatMap { senderNameMode ->
sequenceOf(
aSenderNameData(
senderNameMode = senderNameMode,
),
aSenderNameData(
senderNameMode = senderNameMode,
displayNameAmbiguous = true,
),
SenderNameData(
senderNameMode = senderNameMode,
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
profileDetails = ProfileDetails.Unavailable,
),
)
}
}
private fun aSenderNameData(
senderNameMode: SenderNameMode,
displayNameAmbiguous: Boolean = false,
) = SenderNameData(
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
profileDetails = ProfileDetails.Ready(
displayName = "Alice ${senderNameMode.javaClass.simpleName}",
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = null
),
senderNameMode = senderNameMode,
)
@@ -0,0 +1,19 @@
/*
* 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.matrix.ui.messages.sender
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
sealed interface SenderNameMode {
data class Timeline(val mainColor: Color) : SenderNameMode
data object Reply : SenderNameMode
data object ActionList : SenderNameMode
}
@@ -0,0 +1,56 @@
/*
* 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.matrix.ui.model
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.R
data class InviteSender(
val userId: UserId,
val displayName: String,
val avatarData: AvatarData,
val membershipChangeReason: String?,
) {
@Composable
fun annotatedString(): AnnotatedString {
return stringResource(R.string.screen_invites_invited_you, displayName, userId.value).let { text ->
val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s")
AnnotatedString(
text = text,
spanStyles = listOf(
AnnotatedString.Range(
SpanStyle(
fontWeight = FontWeight.Medium,
color = ElementTheme.colors.textPrimary
),
start = senderNameStart,
end = senderNameStart + displayName.length
)
)
)
}
}
}
fun RoomMember.toInviteSender() = InviteSender(
userId = userId,
displayName = displayName ?: "",
avatarData = getAvatarData(size = AvatarSize.InviteSender),
membershipChangeReason = membershipChangeReason
)
@@ -0,0 +1,38 @@
/*
* 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.libraries.matrix.ui.model
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = size,
)
fun MatrixUser.getBestName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
}
@Composable
fun MatrixUser.getFullName(): String {
return displayName.let { name ->
if (name.isNullOrBlank()) {
userId.value
} else {
stringResource(CommonStrings.common_name_and_id, name, userId.value)
}
}
}
@@ -0,0 +1,36 @@
/*
* 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.matrix.ui.model
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
id = id.value,
name = name,
url = avatarUrl,
size = size,
)
/**
* Returns the role of the user in the room.
* If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns [RoomMember.Role.Owner].
* Otherwise, checks the power levels and returns the corresponding role.
* If no specific power level is set for the user, defaults to [RoomMember.Role.User].
*/
fun RoomInfo.roleOf(userId: UserId): RoomMember.Role {
return if (privilegedCreatorRole && creators.contains(userId)) {
RoomMember.Role.Owner(isCreator = true)
} else {
roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.User
}
}
@@ -0,0 +1,20 @@
/*
* 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.matrix.ui.model
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.RoomMember
fun RoomMember.getAvatarData(size: AvatarSize) = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = size,
)
@@ -0,0 +1,42 @@
/*
* 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.matrix.ui.model
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class SelectRoomInfo(
val roomId: RoomId,
val name: String?,
val canonicalAlias: RoomAlias?,
val avatarUrl: String?,
val heroes: ImmutableList<MatrixUser>,
val isTombstoned: Boolean,
) {
fun getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
name = name,
url = avatarUrl,
size = size,
)
}
fun RoomSummary.toSelectRoomInfo() = SelectRoomInfo(
roomId = roomId,
name = info.name,
avatarUrl = info.avatarUrl,
heroes = info.heroes,
canonicalAlias = info.canonicalAlias,
isTombstoned = info.successorRoom != null,
)
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.model
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility
import io.element.android.libraries.ui.strings.CommonStrings
fun SpaceRoom.getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
name = displayName,
url = avatarUrl,
size = size,
)
val SpaceRoomVisibility.icon: ImageVector
@Composable
get() {
return when (this) {
SpaceRoomVisibility.Private -> CompoundIcons.LockSolid()
SpaceRoomVisibility.Public -> CompoundIcons.Public()
SpaceRoomVisibility.Restricted -> CompoundIcons.Workspace()
}
}
val SpaceRoomVisibility.label: String
@Composable
@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)
}
}
@@ -0,0 +1,63 @@
/*
* 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.matrix.ui.room
import androidx.compose.runtime.Immutable
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import dev.zacsweers.metro.Inject
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@Immutable
sealed interface LoadingRoomState {
data object Loading : LoadingRoomState
data object Error : LoadingRoomState
data class Loaded(val room: JoinedRoom) : LoadingRoomState
}
open class LoadingRoomStateProvider : PreviewParameterProvider<LoadingRoomState> {
override val values: Sequence<LoadingRoomState>
get() = sequenceOf(
LoadingRoomState.Loading,
LoadingRoomState.Error
)
}
@Inject
class LoadingRoomStateFlowFactory(private val matrixClient: MatrixClient) {
fun create(lifecycleScope: CoroutineScope, roomId: RoomId, joinedRoom: JoinedRoom?): StateFlow<LoadingRoomState> {
return if (joinedRoom != null) {
MutableStateFlow<LoadingRoomState>(LoadingRoomState.Loaded(joinedRoom))
} else {
getJoinedRoomFlow(roomId)
.map { room ->
if (room != null) {
LoadingRoomState.Loaded(room)
} else {
LoadingRoomState.Error
}
}
.stateIn(lifecycleScope, SharingStarted.Eagerly, LoadingRoomState.Loading)
}
}
private fun getJoinedRoomFlow(roomId: RoomId): Flow<JoinedRoom?> = suspend {
matrixClient.getJoinedRoom(roomId = roomId)
}
.asFlow()
}
@@ -0,0 +1,55 @@
/*
* 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.libraries.matrix.ui.room
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
@Composable
fun BaseRoom.getRoomMemberAsState(userId: UserId): State<RoomMember?> {
val roomMembersState by membersStateFlow.collectAsState()
return getRoomMemberAsState(roomMembersState = roomMembersState, userId = userId)
}
@Composable
fun getRoomMemberAsState(roomMembersState: RoomMembersState, userId: UserId): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
return remember(roomMembers) {
derivedStateOf {
roomMembers?.find {
it.userId == userId
}
}
}
}
@Composable
fun BaseRoom.getDirectRoomMember(roomMembersState: RoomMembersState): State<RoomMember?> {
val roomInfo by roomInfoFlow.collectAsState()
return remember {
derivedStateOf {
roomMembersState.getDirectRoomMember(roomInfo, sessionId)
}
}
}
@Composable
fun BaseRoom.getCurrentRoomMember(roomMembersState: RoomMembersState): State<RoomMember?> {
return getRoomMemberAsState(roomMembersState = roomMembersState, userId = sessionId)
}
@@ -0,0 +1,131 @@
/*
* 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.libraries.matrix.ui.room
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canHandleKnockRequests
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.ui.model.roleOf
@Composable
fun BaseRoom.canSendMessageAsState(type: MessageEventType, updateKey: Long): State<Boolean> {
return produceState(initialValue = true, key1 = updateKey) {
value = canSendMessage(type).getOrElse { true }
}
}
@Composable
fun BaseRoom.canInviteAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canInvite().getOrElse { false }
}
}
@Composable
fun BaseRoom.canRedactOwnAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canRedactOwn().getOrElse { false }
}
}
@Composable
fun BaseRoom.canRedactOtherAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canRedactOther().getOrElse { false }
}
}
@Composable
fun BaseRoom.canCall(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canUserJoinCall(sessionId).getOrElse { false }
}
}
@Composable
fun BaseRoom.canPinUnpin(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canUserPinUnpin(sessionId).getOrElse { false }
}
}
@Composable
fun BaseRoom.isDmAsState(): State<Boolean> {
return produceState(initialValue = false) {
roomInfoFlow.collect { value = it.isDm }
}
}
@Composable
fun BaseRoom.canKickAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canKick().getOrElse { false }
}
}
@Composable
fun BaseRoom.canBanAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canBan().getOrElse { false }
}
}
@Composable
fun BaseRoom.canHandleKnockRequestsAsState(updateKey: Long): State<Boolean> {
return produceState(initialValue = false, key1 = updateKey) {
value = canHandleKnockRequests().getOrElse { false }
}
}
@Composable
fun BaseRoom.userPowerLevelAsState(updateKey: Long): State<Long> {
return produceState(initialValue = 0, key1 = updateKey) {
value = userRole(sessionId)
.getOrDefault(RoomMember.Role.User)
.powerLevel
}
}
@Composable
fun BaseRoom.isOwnUserAdmin(): Boolean {
val roomInfo by roomInfoFlow.collectAsState()
val role = roomInfo.roleOf(sessionId)
return role == RoomMember.Role.Admin || role is RoomMember.Role.Owner
}
@Composable
fun BaseRoom.rawName(): String? {
val roomInfo by roomInfoFlow.collectAsState()
return roomInfo.rawName
}
@Composable
fun BaseRoom.topic(): String? {
val roomInfo by roomInfoFlow.collectAsState()
return roomInfo.topic
}
@Composable
fun BaseRoom.avatarUrl(): String? {
val roomInfo by roomInfoFlow.collectAsState()
return roomInfo.avatarUrl
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.room
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
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.roomMembers
import io.element.android.libraries.matrix.ui.model.getAvatarData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
@OptIn(ExperimentalCoroutinesApi::class)
fun JoinedRoom.roomMemberIdentityStateChange(waitForEncryption: Boolean): Flow<ImmutableList<RoomMemberIdentityStateChange>> {
val encryptionChangeFlow = flow {
if (waitForEncryption) {
// Room cannot become unencrypted, so it's ok to use first here
roomInfoFlow.first { roomInfo -> roomInfo.isEncrypted == true }
}
emit(Unit)
}
return encryptionChangeFlow
.flatMapLatest {
combine(identityStateChangesFlow, membersStateFlow) { identityStateChanges, membersState ->
identityStateChanges.map { identityStateChange ->
val member = membersState.roomMembers()
?.find { roomMember -> roomMember.userId == identityStateChange.userId }
?.toIdentityRoomMember()
?: createDefaultRoomMemberForIdentityChange(identityStateChange.userId)
RoomMemberIdentityStateChange(
identityRoomMember = member,
identityState = identityStateChange.identityState,
)
}.toImmutableList()
}.distinctUntilChanged()
}
}
private fun RoomMember.toIdentityRoomMember() = IdentityRoomMember(
userId = userId,
displayNameOrDefault = displayNameOrDefault,
avatarData = getAvatarData(AvatarSize.ComposerAlert),
)
private fun createDefaultRoomMemberForIdentityChange(userId: UserId) = IdentityRoomMember(
userId = userId,
displayNameOrDefault = userId.extractedDisplayName,
avatarData = AvatarData(
id = userId.value,
name = null,
url = null,
size = AvatarSize.ComposerAlert,
),
)
data class RoomMemberIdentityStateChange(
val identityRoomMember: IdentityRoomMember,
val identityState: IdentityState,
)
data class IdentityRoomMember(
val userId: UserId,
val displayNameOrDefault: String,
val avatarData: AvatarData,
)
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.room
import io.element.android.libraries.matrix.api.room.RoomMember
import java.text.Collator
// Comparator used to sort room members by power level (descending) and then by name (ascending)
class PowerLevelRoomMemberComparator : Comparator<RoomMember> {
// Used to simplify and compare unicode and ASCII chars (á == a)
private val collator = Collator.getInstance().apply {
decomposition = Collator.CANONICAL_DECOMPOSITION
}
override fun compare(o1: RoomMember, o2: RoomMember): Int {
return when {
o1.powerLevel > o2.powerLevel -> return -1
o1.powerLevel < o2.powerLevel -> return 1
else -> {
collator.compare(o1.sortingName(), o2.sortingName())
}
}
}
}
@@ -0,0 +1,21 @@
/*
* 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.matrix.ui.room
import io.element.android.libraries.matrix.api.room.RoomMember
/**
* Returns the name value to use when sorting room members.
*
* If the display name is not null and not empty, it is returned.
* Otherwise, the user ID is returned without the initial "@".
*/
fun RoomMember.sortingName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value.drop(1)
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.room.address
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TextFieldValidity
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RoomAddressField(
address: String,
homeserverName: String,
addressValidity: RoomAddressValidity,
onAddressChange: (String) -> Unit,
label: String,
supportingText: String,
modifier: Modifier = Modifier,
) {
TextField(
modifier = modifier.testTag(TestTags.roomAddressField),
value = address,
label = label,
leadingIcon = {
Text(
text = "#",
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
trailingIcon = {
Text(
text = homeserverName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
supportingText = when (addressValidity) {
RoomAddressValidity.InvalidSymbols -> {
stringResource(CommonStrings.error_room_address_invalid_symbols)
}
RoomAddressValidity.NotAvailable -> {
stringResource(CommonStrings.error_room_address_already_exists)
}
else -> supportingText
},
validity = when (addressValidity) {
RoomAddressValidity.InvalidSymbols, RoomAddressValidity.NotAvailable -> TextFieldValidity.Invalid
else -> TextFieldValidity.None
},
onValueChange = onAddressChange,
singleLine = true,
)
}
@PreviewsDayNight
@Composable
internal fun RoomAddressFieldPreview() = ElementPreview {
RoomAddressField(
address = "room",
homeserverName = "element.io",
addressValidity = RoomAddressValidity.Valid,
onAddressChange = {},
label = "Room address",
supportingText = "This is the address that people will use to join your room",
)
}
@@ -0,0 +1,23 @@
/*
* 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.matrix.ui.room.address
import androidx.compose.runtime.Immutable
/**
* Represents the validity state of a room address.
* ie. whether it contains invalid characters, is already taken, or is valid.
*/
@Immutable
sealed interface RoomAddressValidity {
data object Unknown : RoomAddressValidity
data object InvalidSymbols : RoomAddressValidity
data object NotAvailable : RoomAddressValidity
data object Valid : RoomAddressValidity
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.room.address
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
import io.element.android.libraries.matrix.api.roomAliasFromName
import kotlinx.coroutines.delay
@Composable
fun RoomAddressValidityEffect(
client: MatrixClient,
roomAliasHelper: RoomAliasHelper,
newRoomAddress: String,
knownRoomAddress: String?,
onRoomAddressValidityChange: (RoomAddressValidity) -> Unit,
) {
val onChange by rememberUpdatedState(onRoomAddressValidityChange)
LaunchedEffect(newRoomAddress) {
if (newRoomAddress.isEmpty() || newRoomAddress == knownRoomAddress) {
onChange(RoomAddressValidity.Unknown)
return@LaunchedEffect
}
// debounce the room address validation
delay(300)
val roomAlias = client.roomAliasFromName(newRoomAddress)
if (roomAlias == null || !roomAliasHelper.isRoomAliasValid(roomAlias)) {
onChange(RoomAddressValidity.InvalidSymbols)
} else {
client.resolveRoomAlias(roomAlias)
.onSuccess { resolved ->
if (resolved.isPresent) {
onChange(RoomAddressValidity.NotAvailable)
} else {
onChange(RoomAddressValidity.Valid)
}
}
.onFailure {
onChange(RoomAddressValidity.Valid)
}
}
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.safety
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.MatrixClient
@Composable
fun MatrixClient.rememberHideInvitesAvatar(): State<Boolean> {
return remember {
mediaPreviewService
.mediaPreviewConfigFlow
.mapState { config -> config.hideInviteAvatar }
}.collectAsState()
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) запрасіў(-ла) вас"</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) ви покани"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Poslat pozvánku"</string>
<string name="screen_bottom_sheet_create_dm_message">"Chcete začít chatovat s %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Poslat pozvánku?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval(a)"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Anfon gwahoddiad"</string>
<string name="screen_bottom_sheet_create_dm_message">"Hoffech chi ddechrau sgwrs gyda %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Anfon gwahoddiad?"</string>
<string name="screen_invites_invited_you">"Mae %1$s (%2$s) wedi eich gwahodd"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Send invitation"</string>
<string name="screen_bottom_sheet_create_dm_message">"Kunne du tænke dig at starte en samtale med %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Send invitation?"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s ) inviterede dig"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Einladung senden"</string>
<string name="screen_bottom_sheet_create_dm_message">"Möchtest du einen Chat mit %1$s starten?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Einladung senden?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Αποστολή πρόσκλησης"</string>
<string name="screen_bottom_sheet_create_dm_message">"Θα θέλατε να ξεκινήσετε μια συνομιλία με τον χρήστη %1$s;"</string>
<string name="screen_bottom_sheet_create_dm_title">"Αποστολή πρόσκλησης;"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) σέ προσκάλεσε"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Enviar invitación"</string>
<string name="screen_bottom_sheet_create_dm_message">"¿Quieres iniciar un chat con %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"¿Enviar invitación?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) te invitó"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Saada kutse"</string>
<string name="screen_bottom_sheet_create_dm_message">"Kas sa soovid alustada vestlust kasutajaga %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Kas saadame kutse?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) saatis sulle kutse"</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Bidali gonbidapena"</string>
<string name="screen_bottom_sheet_create_dm_title">"Gonbidapena bidali?"</string>
<string name="screen_invites_invited_you">"%1$s(e)k (%2$s) gonbidatu zaitu"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"فرستادن دعوت"</string>
<string name="screen_bottom_sheet_create_dm_message">"می‌خواهید گپی را با %1$s بیاغازید؟"</string>
<string name="screen_bottom_sheet_create_dm_title">"فرستادن دعوت؟"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) دعوتتان کرد"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Lähetä kutsu"</string>
<string name="screen_bottom_sheet_create_dm_message">"Haluaisitko aloittaa keskustelun käyttäjän %1$s kanssa?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Lähetetäänkö kutsu?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) kutsui sinut"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Envoyer linvitation"</string>
<string name="screen_bottom_sheet_create_dm_message">"Voulez-vous entamer une discussion avec %1$s ?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Envoyer linvitation ?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vous a invité(e)"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Meghívó küldése"</string>
<string name="screen_bottom_sheet_create_dm_message">"Csevegést kezd vele: %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Meghívó küldése?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) meghívta"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Kirim undangan"</string>
<string name="screen_bottom_sheet_create_dm_message">"Apakah Anda ingin memulai obrolan dengan %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Kirim undangan?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) mengundang Anda"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Invia invito"</string>
<string name="screen_bottom_sheet_create_dm_message">"Vuoi iniziare una conversazione con%1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Inviare invito?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) ti ha invitato"</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) მოგიწვიათ"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"초대장 보내기"</string>
<string name="screen_bottom_sheet_create_dm_message">"%1$s 와 채팅을 시작하시겠습니까?"</string>
<string name="screen_bottom_sheet_create_dm_title">"초대장을 보내시겠습니까?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) 당신을 초대했습니다"</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s(%2$s) pakvietė Jus"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Send invitasjon"</string>
<string name="screen_bottom_sheet_create_dm_message">"Vil du starte en chat med %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Vil du sende invitasjon?"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s) inviterte deg"</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) heeft je uitgenodigd"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Wyślij zaproszenie"</string>
<string name="screen_bottom_sheet_create_dm_message">"Czy chcesz rozpocząć czat z %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Wysłać zaproszenie?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) zaprosił Cię"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Enviar convite"</string>
<string name="screen_bottom_sheet_create_dm_message">"Gostaria de iniciar uma conversa com %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Enviar convite?"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s) convidou você"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Enviar convite"</string>
<string name="screen_bottom_sheet_create_dm_message">"Gostarias de iniciar uma conversa com %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Enviar convite?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) convidou-te"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Trimiteți invitația"</string>
<string name="screen_bottom_sheet_create_dm_message">"Doriți să începeți o discuție cu %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Trimiteți invitația?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) v-a invitat."</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Отправить приглашение"</string>
<string name="screen_bottom_sheet_create_dm_message">"Хотите начать чат с %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Отправить приглашение?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) пригласил вас"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Odoslať pozvánku"</string>
<string name="screen_bottom_sheet_create_dm_message">"Chceli by ste začať rozhovor s používateľom %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Poslať pozvánku?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval/a"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Skicka inbjudan"</string>
<string name="screen_bottom_sheet_create_dm_message">"Vill du starta en chatt med %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Skicka inbjudan?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) bjöd in dig"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Davet gönder"</string>
<string name="screen_bottom_sheet_create_dm_message">"%1$s ile sohbet başlatmak ister misiniz?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Davet gönder?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) sizi davet etti"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Надіслати запрошення"</string>
<string name="screen_bottom_sheet_create_dm_message">"Хочете розпочати бесіду з %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Надіслати запрошення?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) запрошує вас"</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) نے آپ کو مدعو کیا"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Taklif yuborish"</string>
<string name="screen_bottom_sheet_create_dm_message">"%1$s bilan chatni boshlashni xohlaysizmi?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Taklif yuborilsinmi?"</string>
<string name="screen_invites_invited_you">"%1$s(%2$s ) sizni taklif qildi"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"傳送邀請"</string>
<string name="screen_bottom_sheet_create_dm_message">"您想要開始與 %1$s 聊天嗎?"</string>
<string name="screen_bottom_sheet_create_dm_title">"傳送邀請?"</string>
<string name="screen_invites_invited_you">"%1$s%2$s)邀請您"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"发送邀请"</string>
<string name="screen_bottom_sheet_create_dm_message">"您想与%1$s 开始聊天吗?"</string>
<string name="screen_bottom_sheet_create_dm_title">"发送邀请?"</string>
<string name="screen_invites_invited_you">"%1$s %2$s)邀请了你"</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Send invite"</string>
<string name="screen_bottom_sheet_create_dm_message">"Would you like to start a chat with %1$s?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Send invite?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
</resources>
@@ -0,0 +1,108 @@
/*
* 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.libraries.matrix.ui.messages
import android.net.Uri
import com.google.common.truth.Truth.assertThat
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.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class ToHtmlDocumentTest {
@Test
fun `toHtmlDocument - returns null if format is not HTML`() {
val body = FormattedBody(
format = MessageFormat.UNKNOWN,
body = "Hello world"
)
val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
assertThat(document).isNull()
}
@Test
fun `toHtmlDocument - returns a Document if the format is HTML`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "<p>Hello world</p>"
)
val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
assertThat(document).isNotNull()
assertThat(document?.text()).isEqualTo("Hello world")
}
@Test
fun `toHtmlDocument - returns a Document with a prefix if provided`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "<p>Hello world</p>"
)
val document = body.toHtmlDocument(
permalinkParser = FakePermalinkParser(),
prefix = "@Jorge:"
)
assertThat(document).isNotNull()
assertThat(document?.text()).isEqualTo("@Jorge: Hello world")
}
@Test
fun `toHtmlDocument - if a mention is found without an '@' prefix, it will be added`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "Hey <a href='https://matrix.to/#/@alice:matrix.org'>Alice</a>!"
)
val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return PermalinkData.UserLink(UserId("@alice:matrix.org"))
}
})
assertThat(document?.text()).isEqualTo("Hey @Alice!")
}
@Test
fun `toHtmlDocument - if a mention is found with an '@' prefix, nothing will be done`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "Hey <a href='https://matrix.to/#/@alice:matrix.org'>@Alice</a>!"
)
val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return PermalinkData.UserLink(UserId("@alice:matrix.org"))
}
})
assertThat(document?.text()).isEqualTo("Hey @Alice!")
}
@Test
fun `toHtmlDocument - if a link is not a mention, nothing will be done for it`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "Hey <a href='https://matrix.org'>Alice</a>!"
)
val document = body.toHtmlDocument(permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return PermalinkData.FallbackLink(uri = Uri.parse("https://matrix.org"))
}
})
assertThat(document?.text()).isEqualTo("Hey Alice!")
}
}
@@ -0,0 +1,139 @@
/*
* 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.libraries.matrix.ui.messages
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.jsoup.Jsoup
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class ToPlainTextTest {
@Test
fun `Document toPlainText - returns a plain text version of the document`() {
val document = Jsoup.parse(
"""
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
assertThat(document.toPlainText()).isEqualTo(
"""
Hello world
• This is an unordered list.
1. This is an ordered list.
""".trimIndent()
)
}
@Test
fun `FormattedBody toPlainText - returns a plain text version of the HTML body`() {
val formattedBody = FormattedBody(
format = MessageFormat.HTML,
body = """
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
"""
Hello world
• This is an unordered list.
1. This is an ordered list.
""".trimIndent()
)
}
@Test
fun `FormattedBody toPlainText - returns null if the format is not HTML`() {
val formattedBody = FormattedBody(
format = MessageFormat.UNKNOWN,
body = """
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isNull()
}
@Test
fun `TextMessageType toPlainText - returns a plain text version of the HTML body`() {
val messageType = TextMessageType(
body = "Hello world\n- This in an unordered list.\n1. This is an ordered list.\n",
formatted = FormattedBody(
format = MessageFormat.HTML,
body = """
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
)
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
"""
Hello world
• This is an unordered list.
1. This is an ordered list.
""".trimIndent()
)
}
@Test
fun `TextMessageType toPlainText - respects the ol start attr if present`() {
val messageType = TextMessageType(
body = "1. First item\n2. Second item\n",
formatted = FormattedBody(
format = MessageFormat.HTML,
body = """
<ol start='11'>
<li>First item.</li>
<li>Second item.</li>
</ol>
<br />
""".trimIndent()
)
)
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
"""
11. First item.
12. Second item.
""".trimIndent()
)
}
@Test
fun `TextMessageType toPlainText - returns the markdown body if the formatted one cannot be parsed`() {
val messageType = TextMessageType(
body = "This is the fallback text",
formatted = FormattedBody(
format = MessageFormat.UNKNOWN,
body = """
Hello world
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
""".trimIndent()
)
)
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the fallback text")
}
}
@@ -0,0 +1,109 @@
/*
* 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.libraries.matrix.ui.messages.reply
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aProfileDetails
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import org.junit.Test
class InReplyToDetailTest {
@Test
fun `map - with a not ready InReplyTo return expected object`() {
assertThat(
InReplyTo.Pending(AN_EVENT_ID).map(
permalinkParser = FakePermalinkParser()
)
).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
assertThat(
InReplyTo.NotLoaded(AN_EVENT_ID).map(
permalinkParser = FakePermalinkParser()
)
).isEqualTo(InReplyToDetails.Loading(AN_EVENT_ID))
assertThat(
InReplyTo.Error(AN_EVENT_ID, "a message").map(
permalinkParser = FakePermalinkParser()
)
).isEqualTo(InReplyToDetails.Error(AN_EVENT_ID, "a message"))
}
@Test
fun `map - with something other than a MessageContent has no textContent`() {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
content = aRoomMembershipContent(
userId = A_USER_ID,
change = MembershipChange.INVITED,
)
)
val inReplyToDetails = inReplyTo.map(
permalinkParser = FakePermalinkParser()
)
assertThat(inReplyToDetails).isNotNull()
assertThat((inReplyToDetails as InReplyToDetails.Ready).textContent).isNull()
}
@Test
fun `map - with a message content tries to use the formatted text if exists for its textContent`() {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
content = MessageContent(
body = "**Hello!**",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType(
body = "**Hello!**",
formatted = FormattedBody(
format = MessageFormat.HTML,
body = "<p><b>Hello!</b></p>"
)
)
)
)
assertThat(
(inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
).isEqualTo("Hello!")
}
@Test
fun `map - with a message content and no formatted body uses body as fallback for textContent`() {
val inReplyTo = InReplyTo.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
content = MessageContent(
body = "**Hello!**",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType(
body = "**Hello!**",
formatted = null,
)
)
)
assertThat(
(inReplyTo.map(permalinkParser = FakePermalinkParser()) as InReplyToDetails.Ready).textContent
).isEqualTo("**Hello!**")
}
}
@@ -0,0 +1,586 @@
/*
* 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.libraries.matrix.ui.messages.reply
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileDetails
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.tests.testutils.withConfigurationAndContext
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.minutes
@RunWith(AndroidJUnit4::class)
class InReplyToMetadataKtTest {
@Test
fun `any message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(eventContent = aMessageContent()).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Text("textContent"))
}
}
}
@Test
fun `an image message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = ImageMessageType(
filename = "filename",
caption = null,
formattedCaption = null,
source = aMediaSource(),
info = anImageInfo(),
)
)
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `an image message content, no thumbnail`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = ImageMessageType(
filename = "filename",
caption = "caption",
formattedCaption = null,
source = aMediaSource(),
info = anImageInfo(),
)
)
).metadata(hideImage = true)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a sticker message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = StickerContent(
filename = "filename",
body = "body",
info = anImageInfo(),
source = aMediaSource(url = "url")
)
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(url = "url"),
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a sticker message content, no thumbnail`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = StickerContent(
filename = "filename",
body = "body",
info = anImageInfo(),
source = aMediaSource(url = "url")
)
).metadata(hideImage = true)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a video message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = VideoMessageType(
filename = "filename",
caption = null,
formattedCaption = null,
source = aMediaSource(),
info = aVideoInfo(),
)
)
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Video,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a video message content, no thumbnail`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = VideoMessageType(
filename = "filename",
caption = "caption",
formattedCaption = null,
source = aMediaSource(),
info = aVideoInfo(),
)
)
).metadata(hideImage = true)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "body",
type = AttachmentThumbnailType.Video,
blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a file message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = FileMessageType(
filename = "filename",
caption = "caption",
formattedCaption = null,
source = aMediaSource(),
info = FileInfo(
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
),
)
)
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.File,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a file message content, no thumbnail`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = FileMessageType(
filename = "filename",
caption = "caption",
formattedCaption = null,
source = aMediaSource(),
info = FileInfo(
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
),
)
)
).metadata(hideImage = true)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "body",
type = AttachmentThumbnailType.File,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a audio message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = AudioMessageType(
filename = "filename",
caption = "caption",
formattedCaption = null,
source = aMediaSource(),
info = AudioInfo(
duration = null,
size = null,
mimetype = null
),
)
)
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
textContent = "body",
type = AttachmentThumbnailType.Audio,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a location message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
withConfigurationAndContext {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = LocationMessageType(
body = "body",
geoUri = "geo:3.0,4.0;u=5.0",
description = null,
)
)
).metadata(hideImage = false)
}
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Shared location",
type = AttachmentThumbnailType.Location,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a voice message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
withConfigurationAndContext {
anInReplyToDetailsReady(
eventContent = aMessageContent(
messageType = VoiceMessageType(
filename = "filename",
caption = "caption",
formattedCaption = null,
source = aMediaSource(),
info = null,
details = null,
)
)
).metadata(hideImage = false)
}
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Voice message",
type = AttachmentThumbnailType.Voice,
blurHash = null,
)
)
)
}
}
}
@Test
fun `a poll content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aPollContent()
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "Do you like polls?",
type = AttachmentThumbnailType.Poll,
blurHash = null,
)
)
)
}
}
}
@Test
fun `redacted content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = RedactedContent
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Redacted)
}
}
}
@Test
fun `unable to decrypt content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt)
}
}
}
@Test
fun `failed to parse message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = FailedToParseMessageLikeContent("", "")
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `failed to parse state content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = FailedToParseStateContent("", "", "")
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `profile change content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = ProfileChangeContent("", "", "", "")
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `room membership content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = aRoomMembershipContent(userId = A_USER_ID)
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `state content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = StateContent("", OtherState.RoomJoinRules(null))
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `unknown content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = UnknownContent
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `null content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = null
).metadata(hideImage = false)
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
}
private fun anInReplyToDetailsReady(
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID,
senderProfile: ProfileDetails = aProfileDetails(),
eventContent: EventContent? = aMessageContent(),
textContent: String? = "textContent",
) = InReplyToDetails.Ready(
eventId = eventId,
senderId = senderId,
senderProfile = senderProfile,
eventContent = eventContent,
textContent = textContent,
)
fun aVideoInfo(): VideoInfo {
return VideoInfo(
duration = 1.minutes,
height = 100,
width = 100,
mimetype = "video/mp4",
size = 1000,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = A_BLUR_HASH,
)
}
fun anImageInfo(): ImageInfo {
return ImageInfo(
height = 100,
width = 100,
mimetype = MimeTypes.Jpeg,
size = 1000,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = A_BLUR_HASH,
)
}
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.model
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.withConfigurationAndContext
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MatrixUserExtensionsTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `getAvatarData should return the expected value`() {
val matrixUser = MatrixUser(
userId = A_USER_ID,
displayName = "displayName",
avatarUrl = "avatarUrl",
)
val expected = AvatarData(
id = A_USER_ID.value,
name = "displayName",
url = "avatarUrl",
size = AvatarSize.UserHeader,
)
assertThat(matrixUser.getAvatarData(AvatarSize.UserHeader)).isEqualTo(expected)
}
@Test
fun `getBestName should return the display name is available`() {
val matrixUser = MatrixUser(
userId = A_USER_ID,
displayName = "displayName",
)
assertThat(matrixUser.getBestName()).isEqualTo("displayName")
}
@Test
fun `getBestName should return the id when name is not available`() {
val matrixUser = MatrixUser(
userId = A_USER_ID,
displayName = null,
)
assertThat(matrixUser.getBestName()).isEqualTo(A_USER_ID.value)
}
@Test
fun `getBestName should return the id when name is empty`() {
val matrixUser = MatrixUser(
userId = A_USER_ID,
displayName = "",
)
assertThat(matrixUser.getBestName()).isEqualTo(A_USER_ID.value)
}
@Test
fun `getFullName should return the display name is available and the userId`() = runTest {
val matrixUser = MatrixUser(
userId = A_USER_ID,
displayName = "displayName",
)
moleculeFlow(RecompositionMode.Immediate) {
withConfigurationAndContext {
matrixUser.getFullName()
}
}.test {
assertThat(awaitItem()).isEqualTo("displayName (@alice:server.org)")
}
}
@Test
fun `getBestName should return only the id when name is not available`() = runTest {
val matrixUser = MatrixUser(
userId = A_USER_ID,
displayName = null,
)
moleculeFlow(RecompositionMode.Immediate) {
matrixUser.getFullName()
}.test {
assertThat(awaitItem()).isEqualTo(A_USER_ID.value)
}
}
}
@@ -0,0 +1,79 @@
/*
* 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.libraries.matrix.ui.model
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
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_3
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import kotlinx.collections.immutable.toImmutableMap
import org.junit.Test
class RoomInfoExtensionTest {
@Test
fun `roleOf returns Owner for creator with privilegedCreatorRole true`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = true,
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.Owner(isCreator = true))
}
@Test
fun `roleOf returns User for not creator with privilegedCreatorRole true`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = true,
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.User)
}
@Test
fun `roleOf returns User for creator with privilegedCreatorRole false`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = false,
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.User)
}
@Test
fun `roleOf returns role from the power level`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = false,
roomPowerLevels = RoomPowerLevels(
values = defaultRoomPowerLevelValues(),
users = mapOf(
A_USER_ID to 100L, // Admin
A_USER_ID_2 to 50L, // Moderator
A_USER_ID_3 to 0L, // User
).toImmutableMap(),
),
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.Admin)
assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.Moderator)
assertThat(roomInfo.roleOf(A_USER_ID_3)).isEqualTo(RoomMember.Role.User)
}
@Test
fun `roleOf returns User when the power level is null`() {
val roomInfo = aRoomInfo(
privilegedCreatorRole = false,
roomPowerLevels = null,
roomCreators = listOf(A_USER_ID),
)
assertThat(roomInfo.roleOf(A_USER_ID)).isEqualTo(RoomMember.Role.User)
assertThat(roomInfo.roleOf(A_USER_ID_2)).isEqualTo(RoomMember.Role.User)
assertThat(roomInfo.roleOf(A_USER_ID_3)).isEqualTo(RoomMember.Role.User)
}
}
@@ -0,0 +1,404 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.matrix.ui.room
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.room.RoomMembersState
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_3
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 kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ObserveRoomMemberIdentityStateChangeTest {
private val aliceRoomMember = aRoomMember(A_USER_ID, displayName = "Alice")
private val bobRoomMember = aRoomMember(A_USER_ID_2, displayName = "Bob")
private val carolRoomMember = aRoomMember(A_USER_ID_3, displayName = "Carol")
@Test
fun `roomMemberIdentityStateChange emits empty list for non-encrypted room with no identity changes`() =
runTest {
val identityStateChangesFlow = MutableStateFlow<List<IdentityStateChange>>(emptyList())
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = false))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val result = awaitItem()
assertThat(result).isEmpty()
}
}
@Test
fun `roomMemberIdentityStateChange emits identity changes for non-encrypted room when waitForEncryption is false`() =
runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.Verified),
IdentityStateChange(carolRoomMember.userId, IdentityState.PinViolation)
)
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = false))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember,
carolRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val result = awaitItem()
assertThat(result).hasSize(2)
val bobChange = result.find { it.identityRoomMember.userId == bobRoomMember.userId }
assertThat(bobChange).isNotNull()
assertThat(bobChange?.identityState).isEqualTo(IdentityState.Verified)
assertThat(bobChange?.identityRoomMember?.displayNameOrDefault).isEqualTo("Bob")
val carolChange = result.find { it.identityRoomMember.userId == carolRoomMember.userId }
assertThat(carolChange).isNotNull()
assertThat(carolChange?.identityState).isEqualTo(IdentityState.PinViolation)
assertThat(carolChange?.identityRoomMember?.displayNameOrDefault).isEqualTo("Carol")
}
}
@Test
fun `roomMemberIdentityStateChange emits identity changes for already encrypted room`() =
runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.VerificationViolation)
)
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test {
val result = awaitItem()
assertThat(result).hasSize(1)
val bobChange = result.first()
assertThat(bobChange.identityRoomMember.userId).isEqualTo(bobRoomMember.userId)
assertThat(bobChange.identityState).isEqualTo(IdentityState.VerificationViolation)
assertThat(bobChange.identityRoomMember.displayNameOrDefault).isEqualTo("Bob")
}
}
@Test
fun `roomMemberIdentityStateChange waits for encryption before emitting when waitForEncryption is true`() =
runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = false))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test {
// Should not emit anything yet since room is not encrypted
expectNoEvents()
// Enable encryption
joinedRoom.baseRoom.givenRoomInfo(aRoomInfo(isEncrypted = true))
val result = awaitItem()
assertThat(result).hasSize(1)
assertThat(result.first().identityRoomMember.userId).isEqualTo(bobRoomMember.userId)
assertThat(result.first().identityState).isEqualTo(IdentityState.Pinned)
}
}
@Test
fun `roomMemberIdentityStateChange creates default member when room member not found`() =
runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(carolRoomMember.userId, IdentityState.PinViolation))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
// Only include aliceRoomMember and bobRoomMember, not carolRoomMember
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val result = awaitItem()
assertThat(result).hasSize(1)
val carolChange = result.first()
assertThat(carolChange.identityRoomMember.userId).isEqualTo(carolRoomMember.userId)
assertThat(carolChange.identityState).isEqualTo(IdentityState.PinViolation)
// Should use extracted display name from user ID since member not found
assertThat(carolChange.identityRoomMember.displayNameOrDefault).isEqualTo(
carolRoomMember.userId.extractedDisplayName
)
}
}
@Test
fun `roomMemberIdentityStateChange updates when identity state changes`() = runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val firstResult = awaitItem()
assertThat(firstResult).hasSize(1)
assertThat(firstResult.first().identityState).isEqualTo(IdentityState.Pinned)
// Update identity state
identityStateChangesFlow.value = listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.VerificationViolation)
)
val secondResult = awaitItem()
assertThat(secondResult).hasSize(1)
assertThat(secondResult.first().identityState).isEqualTo(IdentityState.VerificationViolation)
}
}
@Test
fun `roomMemberIdentityStateChange updates when members state changes`() = runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Verified))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val firstResult = awaitItem()
assertThat(firstResult).hasSize(1)
assertThat(firstResult.first().identityRoomMember.displayNameOrDefault).isEqualTo("Bob")
// Update room member with different display name
val updatedMember2 = bobRoomMember.copy(displayName = "Bobby")
joinedRoom.baseRoom.givenRoomMembersState(
RoomMembersState.Ready(persistentListOf(aliceRoomMember, updatedMember2))
)
val secondResult = awaitItem()
assertThat(secondResult).hasSize(1)
assertThat(secondResult.first().identityRoomMember.displayNameOrDefault).isEqualTo("Bobby")
}
}
@Test
fun `roomMemberIdentityStateChange handles multiple identity states`() = runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(
IdentityStateChange(aliceRoomMember.userId, IdentityState.Verified),
IdentityStateChange(bobRoomMember.userId, IdentityState.PinViolation),
IdentityStateChange(carolRoomMember.userId, IdentityState.VerificationViolation)
)
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember,
carolRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
val result = awaitItem()
assertThat(result).hasSize(3)
val verifiedUser = result.find { it.identityState == IdentityState.Verified }
assertThat(verifiedUser?.identityRoomMember?.userId).isEqualTo(aliceRoomMember.userId)
val pinViolationUser = result.find { it.identityState == IdentityState.PinViolation }
assertThat(pinViolationUser?.identityRoomMember?.userId).isEqualTo(bobRoomMember.userId)
val verificationViolationUser =
result.find { it.identityState == IdentityState.VerificationViolation }
assertThat(verificationViolationUser?.identityRoomMember?.userId).isEqualTo(carolRoomMember.userId)
}
}
@Test
fun `roomMemberIdentityStateChange handles room becoming encrypted scenario`() = runTest {
val identityStateChangesFlow = MutableStateFlow(
listOf(IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned))
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = false))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = true).test {
// Should not emit anything initially as room is not encrypted
expectNoEvents()
// Room becomes encrypted
joinedRoom.baseRoom.givenRoomInfo(aRoomInfo(isEncrypted = true))
val result = awaitItem()
assertThat(result).hasSize(1)
assertThat(result.first().identityRoomMember.userId).isEqualTo(bobRoomMember.userId)
assertThat(result.first().identityState).isEqualTo(IdentityState.Pinned)
// Add more identity changes after encryption is enabled
identityStateChangesFlow.value = listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.Pinned),
IdentityStateChange(aliceRoomMember.userId, IdentityState.VerificationViolation)
)
val updatedResult = awaitItem()
assertThat(updatedResult).hasSize(2)
}
}
@Test
fun `roomMemberIdentityStateChange does not emit duplicates for same state`() = runTest {
val identityStateChangesFlow = MutableSharedFlow<List<IdentityStateChange>>()
val identityStateChanges = listOf(
IdentityStateChange(bobRoomMember.userId, IdentityState.Verified)
)
val joinedRoom = FakeJoinedRoom(
identityStateChangesFlow = identityStateChangesFlow,
baseRoom = FakeJoinedRoom().baseRoom.apply {
givenRoomInfo(aRoomInfo(isEncrypted = true))
givenRoomMembersState(
RoomMembersState.Ready(
persistentListOf(
aliceRoomMember,
bobRoomMember
)
)
)
}
)
joinedRoom.roomMemberIdentityStateChange(waitForEncryption = false).test {
identityStateChangesFlow.emit(identityStateChanges)
val firstResult = awaitItem()
assertThat(firstResult).hasSize(1)
// Emit the same state again
identityStateChangesFlow.emit(identityStateChanges)
// Should not emit a new item due to distinctUntilChanged
expectNoEvents()
}
}
}
@@ -0,0 +1,80 @@
/*
* 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.matrix.ui.room
import io.element.android.libraries.matrix.api.core.UserId
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_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5
import io.element.android.libraries.matrix.test.room.aRoomMember
import org.junit.Test
class PowerLevelRoomMemberComparatorTest {
@Test
fun `order is Admin, then Moderator, then User`() {
val memberList = listOf(
aRoomMember(userId = UserId("@admin:example.com"), powerLevel = 100),
aRoomMember(userId = UserId("@moderator:example.com"), powerLevel = 50),
aRoomMember(userId = UserId("@user:example.com"), powerLevel = 0),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == UserId("@admin:example.com"))
assert(ordered[1].userId == UserId("@moderator:example.com"))
assert(ordered[2].userId == UserId("@user:example.com"))
}
@Test
fun `with the same power level, alphabetical ascending order for name is used`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "First - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, displayName = "Second - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, displayName = "Third - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_4, displayName = "First - user", powerLevel = 0),
aRoomMember(userId = A_USER_ID_5, displayName = "Second - user", powerLevel = 0),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID)
assert(ordered[1].userId == A_USER_ID_2)
assert(ordered[2].userId == A_USER_ID_3)
assert(ordered[3].userId == A_USER_ID_4)
assert(ordered[4].userId == A_USER_ID_5)
}
@Test
fun `when no names are provided, alphabetical order uses user id`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "Z - LAST!", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, powerLevel = 100),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID_2)
assert(ordered[1].userId == A_USER_ID_3)
assert(ordered[2].userId == A_USER_ID)
}
@Test
fun `unicode characters are simplified and compared, order ignores case`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "First", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, displayName = "Șecond", powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, displayName = "third", powerLevel = 100),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID)
assert(ordered[1].userId == A_USER_ID_2)
assert(ordered[2].userId == A_USER_ID_3)
}
}

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