Merge branch 'develop' into feature/bma/scroll-to-unread-messages

This commit is contained in:
Benoit Marty
2026-07-03 14:30:14 +02:00
committed by GitHub
1562 changed files with 11824 additions and 6140 deletions
@@ -6,25 +6,19 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.atomic.organisms
package io.element.android.libraries.designsystem.components.visuallist
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItemMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItemPosition
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
@@ -32,14 +26,17 @@ import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2001-140
*/
@Composable
fun InfoListOrganism(
items: ImmutableList<InfoListItem>,
fun VisualList(
items: ImmutableList<VisualListItemData>,
modifier: Modifier = Modifier,
backgroundColor: Color = ElementTheme.colors.bgSubtleSecondary,
iconTint: Color = LocalContentColor.current,
iconTint: Color = ElementTheme.colors.iconSecondary,
iconSize: Dp = 20.dp,
textStyle: TextStyle = LocalTextStyle.current,
textStyle: TextStyle = ElementTheme.typography.fontBodyMdMedium,
textColor: Color = ElementTheme.colors.textPrimary,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
) {
@@ -49,12 +46,12 @@ fun InfoListOrganism(
) {
for ((index, item) in items.withIndex()) {
val position = when {
items.size == 1 -> InfoListItemPosition.Single
index == 0 -> InfoListItemPosition.Top
index == items.size - 1 -> InfoListItemPosition.Bottom
else -> InfoListItemPosition.Middle
items.size == 1 -> VisualListItemPosition.Single
index == 0 -> VisualListItemPosition.Top
index == items.size - 1 -> VisualListItemPosition.Bottom
else -> VisualListItemPosition.Middle
}
InfoListItemMolecule(
VisualListItem(
message = {
if (item.message is AnnotatedString) {
Text(
@@ -96,22 +93,15 @@ fun InfoListOrganism(
}
}
data class InfoListItem(
val message: CharSequence,
@DrawableRes val iconId: Int? = null,
val iconVector: ImageVector? = null,
val iconComposable: @Composable () -> Unit = {},
)
@PreviewsDayNight
@Composable
internal fun InfoListOrganismPreview() = ElementPreview {
internal fun VisualListPreview() = ElementPreview {
val items = persistentListOf(
InfoListItem(message = "A top item"),
InfoListItem(message = "A middle item"),
InfoListItem(message = "A bottom item"),
VisualListItemData(message = "A top item"),
VisualListItemData(message = "A middle item"),
VisualListItemData(message = "A bottom item"),
)
InfoListOrganism(
VisualList(
items = items,
)
}
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.atomic.molecules
package io.element.android.libraries.designsystem.components.visuallist
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -27,10 +27,13 @@ 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
/**
* Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2001-159
*/
@Composable
fun InfoListItemMolecule(
fun VisualListItem(
message: @Composable () -> Unit,
position: InfoListItemPosition,
position: VisualListItemPosition,
backgroundColor: Color,
modifier: Modifier = Modifier,
icon: @Composable () -> Unit = {},
@@ -38,10 +41,10 @@ fun InfoListItemMolecule(
val radius = 14.dp
val backgroundShape = remember(position) {
when (position) {
InfoListItemPosition.Single -> RoundedCornerShape(radius)
InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
InfoListItemPosition.Middle -> RoundedCornerShape(0.dp)
InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
VisualListItemPosition.Single -> RoundedCornerShape(radius)
VisualListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
VisualListItemPosition.Middle -> RoundedCornerShape(0.dp)
VisualListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
}
}
Row(
@@ -61,42 +64,42 @@ fun InfoListItemMolecule(
@PreviewsDayNight
@Composable
internal fun InfoListItemMoleculePreview() {
internal fun VisualListItemPreview() {
ElementPreview {
val color = ElementTheme.colors.bgSubtleSecondary
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
InfoListItemMolecule(
VisualListItem(
message = { Text("A single item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
position = InfoListItemPosition.Single,
position = VisualListItemPosition.Single,
backgroundColor = color,
)
InfoListItemMolecule(
VisualListItem(
message = { Text("A top item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
position = InfoListItemPosition.Top,
position = VisualListItemPosition.Top,
backgroundColor = color,
)
InfoListItemMolecule(
VisualListItem(
message = { Text("A middle item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
position = InfoListItemPosition.Middle,
position = VisualListItemPosition.Middle,
backgroundColor = color,
)
InfoListItemMolecule(
VisualListItem(
message = { Text("A bottom item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
position = InfoListItemPosition.Bottom,
position = VisualListItemPosition.Bottom,
backgroundColor = color,
)
}
}
}
enum class InfoListItemPosition {
enum class VisualListItemPosition {
Top,
Middle,
Bottom,
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.visuallist
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
data class VisualListItemData(
val message: CharSequence,
@DrawableRes val iconId: Int? = null,
val iconVector: ImageVector? = null,
val iconComposable: @Composable () -> Unit = {},
)
@@ -0,0 +1,49 @@
/*
* 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.designsystem.modifiers
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
/**
* Keeps the focused field visible above the keyboard. Intended for a field inside a scrollable
* container: the field is never clipped by a pinned footer, but the IME is shown only after focus
* arrives, so we re-request bringIntoView once it is visible to scroll the field back into the
* reduced viewport.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Modifier.bringIntoViewOnImeVisible(): Modifier {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var isFocused by remember { mutableStateOf(false) }
val isImeVisible = WindowInsets.isImeVisible
LaunchedEffect(isImeVisible, isFocused) {
if (isImeVisible && isFocused) {
// Delay to ensure the keyboard is fully shown before scrolling the field into view.
delay(100.milliseconds)
bringIntoViewRequester.bringIntoView()
}
}
return this
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged { isFocused = it.isFocused }
}
@@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DividerDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -18,6 +17,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@@ -25,7 +25,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
fun HorizontalDivider(
modifier: Modifier = Modifier,
thickness: Dp = ElementDividerDefaults.thickness,
color: Color = DividerDefaults.color,
color: Color = ElementDividerDefaults.color,
) {
androidx.compose.material3.HorizontalDivider(
modifier = modifier,
@@ -35,7 +35,9 @@ fun HorizontalDivider(
}
object ElementDividerDefaults {
val thickness = 0.5.dp
val thickness = 1.dp
val color
@Composable get() = ElementTheme.colors.separatorSecondary
}
@Preview(group = PreviewGroup.Dividers)
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Show/hide toggle for a password [TextField], intended for its `trailingIcon` slot.
* Shared so every password field reveals plaintext the same way and announces the
* same accessibility labels.
*/
@Composable
fun PasswordVisibilityToggle(
visible: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier.clickable(onClick = onToggle)) {
Icon(
imageVector = if (visible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff(),
contentDescription = stringResource(
if (visible) CommonStrings.a11y_hide_password else CommonStrings.a11y_show_password
),
)
}
}
@@ -18,39 +18,35 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.theme.components.Snackbar
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
*
* The current head of the queue is exposed as a [MutableStateFlow] so that every collector — including
* hosts that subscribe *after* a message was posted (e.g. a screen recomposing as a flow pops back to
* it) — observes the current message. An earlier mutex-gated implementation delivered each message to
* whichever single collector happened to be parked on the lock, which could starve the host that was
* actually on screen, dropping the snackbar.
*/
class SnackbarDispatcher {
private val queueMutex = Mutex()
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
val snackbarMessage: Flow<SnackbarMessage?> = flow {
while (currentCoroutineContext().isActive) {
queueMutex.lock()
emit(snackBarMessageQueue.firstOrNull())
}
}
private val currentMessage = MutableStateFlow<SnackbarMessage?>(null)
val snackbarMessage: Flow<SnackbarMessage?> = currentMessage.asStateFlow()
@Synchronized
fun post(message: SnackbarMessage) {
if (snackBarMessageQueue.isEmpty()) {
snackBarMessageQueue.add(message)
if (queueMutex.isLocked) queueMutex.unlock()
} else {
snackBarMessageQueue.add(message)
}
snackBarMessageQueue.add(message)
currentMessage.value = snackBarMessageQueue.firstOrNull()
}
@Synchronized
fun clear() {
if (snackBarMessageQueue.isNotEmpty()) {
snackBarMessageQueue.removeFirstOrNull()
if (queueMutex.isLocked) queueMutex.unlock()
}
snackBarMessageQueue.removeFirstOrNull()
currentMessage.value = snackBarMessageQueue.firstOrNull()
}
}
@@ -54,6 +54,27 @@ class SnackbarDispatcherTest {
}
}
@Test
fun `a host that subscribes after a message is posted still receives it`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val message = SnackbarMessage(0)
// A first host is already collecting (mirrors a background screen still subscribed).
snackbarDispatcher.snackbarMessage.test {
assertThat(awaitItem()).isNull()
snackbarDispatcher.post(message)
assertThat(awaitItem()).isEqualTo(message)
// A second host subscribes only afterwards — e.g. the screen a flow pops back to once the
// message has already been posted. It must observe the current message rather than being
// starved waiting for the next post (regression: an earlier mutex-gated implementation
// delivered each post to a single parked collector, dropping the snackbar for any host
// that subscribed later).
snackbarDispatcher.snackbarMessage.test {
assertThat(awaitItem()).isEqualTo(message)
}
}
}
@Test
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()