First Commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+152
@@ -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))
|
||||
)
|
||||
}
|
||||
+42
@@ -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"
|
||||
+122
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
+161
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+109
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+137
@@ -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",
|
||||
)
|
||||
}
|
||||
+161
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
+36
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+103
@@ -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)
|
||||
}
|
||||
+64
@@ -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()
|
||||
}
|
||||
+61
@@ -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"),
|
||||
)
|
||||
+39
@@ -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)
|
||||
}
|
||||
+79
@@ -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,
|
||||
)
|
||||
}
|
||||
+42
@@ -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,
|
||||
)
|
||||
+149
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+76
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+149
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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,
|
||||
)
|
||||
}
|
||||
+115
@@ -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,
|
||||
)
|
||||
}
|
||||
+123
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+108
@@ -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,
|
||||
)
|
||||
}
|
||||
+278
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
+79
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+103
@@ -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)
|
||||
}
|
||||
}
|
||||
+93
@@ -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())
|
||||
}
|
||||
}
|
||||
+75
@@ -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()
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
)
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
+44
@@ -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]
|
||||
}
|
||||
}
|
||||
+62
@@ -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("@")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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()
|
||||
}
|
||||
}
|
||||
+66
@@ -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)
|
||||
}
|
||||
+168
@@ -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,
|
||||
)
|
||||
+131
@@ -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
|
||||
}
|
||||
+214
@@ -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,
|
||||
)
|
||||
}
|
||||
+127
@@ -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,
|
||||
)
|
||||
}
|
||||
+58
@@ -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,
|
||||
)
|
||||
+19
@@ -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
|
||||
}
|
||||
+56
@@ -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
|
||||
)
|
||||
+38
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
+20
@@ -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,
|
||||
)
|
||||
+42
@@ -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,
|
||||
)
|
||||
+48
@@ -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)
|
||||
}
|
||||
}
|
||||
+63
@@ -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()
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
+131
@@ -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
|
||||
}
|
||||
+81
@@ -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,
|
||||
)
|
||||
+29
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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)
|
||||
}
|
||||
+81
@@ -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",
|
||||
)
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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 l’invitation"</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 l’invitation ?"</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>
|
||||
+108
@@ -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!")
|
||||
}
|
||||
}
|
||||
+139
@@ -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")
|
||||
}
|
||||
}
|
||||
+109
@@ -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!**")
|
||||
}
|
||||
}
|
||||
+586
@@ -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,
|
||||
)
|
||||
}
|
||||
+102
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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)
|
||||
}
|
||||
}
|
||||
+404
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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
Reference in New Issue
Block a user