mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 01:19:33 +00:00
Merge branch 'develop' into bma/settingsIterationStep2Bis
This commit is contained in:
+50
-36
@@ -8,39 +8,43 @@
|
||||
|
||||
package io.element.android.libraries.designsystem.atomic.atoms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
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.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceAtLeast
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.SemanticColors
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
private const val MAX_COUNT = 99
|
||||
private const val MAX_COUNT_STRING = "+$MAX_COUNT"
|
||||
private const val MAX_COUNT_STRING = "$MAX_COUNT+"
|
||||
|
||||
/**
|
||||
* A counter atom that displays a number in a circle.
|
||||
* Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev
|
||||
*
|
||||
* @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING].
|
||||
* If the number is less than 1, the counter will not be displayed.
|
||||
* If the number is less than 1, just a dot will be displayed instead.
|
||||
* @param modifier The modifier to apply to this layout.
|
||||
* @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default.
|
||||
* @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary].
|
||||
* @param contentColor The text color inside the counter. When null, uses [SemanticColors.textOnSolidPrimary].
|
||||
* @param contentPadding The padding to apply to the text inside the counter.
|
||||
* @param textStyle The style to apply to the text inside the counter.
|
||||
* @param dotSize The size of the dot when [count] is less than 1. It defaults to `12.0.dp`.
|
||||
* @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme.
|
||||
* Only used when [containerColor] is null.
|
||||
*/
|
||||
@@ -50,41 +54,51 @@ fun CounterAtom(
|
||||
modifier: Modifier = Modifier,
|
||||
containerColor: Color? = null,
|
||||
contentColor: Color? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
textStyle: TextStyle = CounterAtomDefaults.textStyle,
|
||||
dotSize: Dp = 12.0.dp,
|
||||
isCritical: Boolean = false,
|
||||
) {
|
||||
if (count < 1) return
|
||||
val countAsText = when (count) {
|
||||
in 0..MAX_COUNT -> count.toString()
|
||||
in Int.MIN_VALUE..0 -> null
|
||||
in 1..MAX_COUNT -> count.toString()
|
||||
else -> MAX_COUNT_STRING
|
||||
}
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
// Measure the maximum count string size
|
||||
val textLayoutResult = textMeasurer.measure(
|
||||
text = MAX_COUNT_STRING,
|
||||
style = textStyle
|
||||
)
|
||||
val textSize = textLayoutResult.size
|
||||
val squareSize = maxOf(textSize.width, textSize.height)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(squareSize.toDp() + 1.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
containerColor ?: if (isCritical) {
|
||||
ElementTheme.colors.iconCriticalPrimary
|
||||
} else {
|
||||
ElementTheme.colors.iconAccentPrimary
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
|
||||
val minSingleDigitSize = if (countAsText?.length == 1) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
// Measure the maximum count string size
|
||||
val textLayoutResult = textMeasurer.measure(
|
||||
text = countAsText,
|
||||
style = textStyle,
|
||||
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
style = textStyle
|
||||
)
|
||||
val textSize = textLayoutResult.size
|
||||
maxOf(textSize.width, textSize.height)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
androidx.compose.material3.Badge(
|
||||
modifier = modifier.then(
|
||||
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier
|
||||
),
|
||||
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary,
|
||||
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
content = countAsText?.let {
|
||||
@Composable {
|
||||
Text(
|
||||
modifier = Modifier.sizeIn(
|
||||
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
|
||||
minHeight = minSingleDigitSize.toDp()
|
||||
).padding(contentPadding),
|
||||
text = countAsText,
|
||||
style = textStyle,
|
||||
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
object CounterAtomDefaults {
|
||||
@@ -95,7 +109,7 @@ object CounterAtomDefaults {
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CounterAtomPreview() = ElementPreview {
|
||||
Column(verticalArrangement = spacedBy(2.dp)) {
|
||||
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CounterAtom(count = 0)
|
||||
CounterAtom(count = 4)
|
||||
CounterAtom(count = 99)
|
||||
|
||||
+28
-26
@@ -9,15 +9,16 @@
|
||||
package io.element.android.libraries.designsystem.atomic.atoms
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -37,35 +38,36 @@ fun UnreadIndicatorAtom(
|
||||
isVisible: Boolean = true,
|
||||
contentDescription: String? = null,
|
||||
border: BorderStroke? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
) {
|
||||
when {
|
||||
!isVisible -> Spacer(modifier = modifier.size(size))
|
||||
count != null && count >= 1 -> CounterAtom(
|
||||
count = count.toInt(),
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
|
||||
containerColor = color,
|
||||
contentColor = ElementTheme.colors.bgCanvasDefault,
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
else -> Box(
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
|
||||
)
|
||||
else -> count?.let {
|
||||
CounterAtom(
|
||||
count = count.toInt(),
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier),
|
||||
containerColor = color,
|
||||
contentColor = ElementTheme.colors.bgCanvasDefault,
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UnreadIndicatorAtomPreview() = ElementPreview {
|
||||
UnreadIndicatorAtom()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
UnreadIndicatorAtom(count = null)
|
||||
UnreadIndicatorAtom(count = 0)
|
||||
UnreadIndicatorAtom(count = 1)
|
||||
UnreadIndicatorAtom(count = 10)
|
||||
UnreadIndicatorAtom(count = 99)
|
||||
UnreadIndicatorAtom(count = 999)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -32,8 +32,8 @@ import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.text.util.LinkifyCompat
|
||||
import io.element.android.compound.theme.LinkColor
|
||||
import io.element.android.libraries.androidutils.text.LinkifyHelper
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
@@ -142,7 +142,7 @@ fun ClickableLinkText(
|
||||
fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
|
||||
val original = this
|
||||
val spannable = SpannableString.valueOf(this.text)
|
||||
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
|
||||
LinkifyHelper.linkify(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
|
||||
|
||||
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
|
||||
return buildAnnotatedString {
|
||||
@@ -150,7 +150,9 @@ fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
|
||||
for (span in spans) {
|
||||
val start = spannable.getSpanStart(span)
|
||||
val end = spannable.getSpanEnd(span)
|
||||
if (original.getLinkAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) {
|
||||
if (original.getLinkAnnotations(start, end).isEmpty() &&
|
||||
original.getStringAnnotations(LINK_TAG, start, end).isEmpty() &&
|
||||
original.getStringAnnotations("inline_code", start, end).isEmpty()) {
|
||||
// Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com)
|
||||
if (start > 0 && !spannable[start - 1].isWhitespace()) continue
|
||||
|
||||
|
||||
+60
-27
@@ -8,17 +8,18 @@
|
||||
package io.element.android.libraries.designsystem.theme.components
|
||||
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FloatingToolbarColors
|
||||
import androidx.compose.material3.FloatingToolbarDefaults
|
||||
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
|
||||
@@ -28,7 +29,6 @@ import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
@@ -139,8 +139,20 @@ fun HorizontalFloatingToolbarItem(
|
||||
contentColor = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
Box {
|
||||
FilledIconButton(
|
||||
BadgedBox(
|
||||
badge = {
|
||||
counter?.let {
|
||||
CounterAtom(
|
||||
count = it,
|
||||
// Tweak the offset so it's centered on the icon
|
||||
modifier = Modifier.offset(x = (-16).dp, y = 10.dp),
|
||||
contentPadding = PaddingValues(vertical = 0.5.dp),
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.widthIn(min = 56.dp),
|
||||
colors = colors,
|
||||
onClick = onClick,
|
||||
@@ -151,15 +163,6 @@ fun HorizontalFloatingToolbarItem(
|
||||
contentDescription = tooltipLabel,
|
||||
)
|
||||
}
|
||||
if (counter != null) {
|
||||
CounterAtom(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 6.dp, end = 3.dp),
|
||||
count = counter,
|
||||
textStyle = ElementTheme.typography.fontBodyXsMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,18 +175,47 @@ fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) {
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HorizontalFloatingToolbarPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Column {
|
||||
ContentToPreview(
|
||||
count = 1,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
ContentToPreview(
|
||||
count = 99,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
ContentToPreview(
|
||||
count = 999,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@@ -197,10 +229,11 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ContentToPreview(
|
||||
count: Int? = 6,
|
||||
floatingActionButton: (@Composable () -> Unit)?,
|
||||
) {
|
||||
HorizontalFloatingToolbar(
|
||||
modifier = Modifier.padding(28.dp),
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp),
|
||||
floatingActionButton = floatingActionButton,
|
||||
) {
|
||||
listOf(
|
||||
@@ -214,7 +247,7 @@ private fun ContentToPreview(
|
||||
icon = icon,
|
||||
tooltipLabel = "Label",
|
||||
isSelected = index == 0,
|
||||
counter = if (index == 0) 6 else null,
|
||||
counter = if (index == 0) count else null,
|
||||
forceRenderingTooltip = true,
|
||||
onClick = { },
|
||||
)
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import java.io.FileOutputStream
|
||||
*
|
||||
* It uses hardcoded constants that are used in that library, for backwards compatibility reasons.
|
||||
*/
|
||||
internal class EncryptedFile(
|
||||
class EncryptedFile(
|
||||
private val context: Context,
|
||||
private val file: File
|
||||
) {
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.libraries.htmlrenderer.api
|
||||
|
||||
import io.element.android.libraries.designsystem.components.LINK_TAG
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,7 @@ interface HtmlMessageParser {
|
||||
* Tag used for the string annotation attached to link (`<a href>`) text ranges in a
|
||||
* [ParagraphNode.text]. The annotation value is the link target URL.
|
||||
*/
|
||||
const val LINK_ANNOTATION_TAG = "url"
|
||||
const val LINK_ANNOTATION_TAG = LINK_TAG
|
||||
|
||||
/**
|
||||
* Tag used for the string annotation attached to inline code (`<code>`) text ranges in a
|
||||
|
||||
+12
@@ -54,6 +54,18 @@ data class ParagraphNode(
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
|
||||
) : BlockNode
|
||||
|
||||
/**
|
||||
* A heading (`<h1>`–`<h6>`). Like a [ParagraphNode] it carries inline content ([text] and
|
||||
* [inlineContent]), plus the heading [level] from 1 (largest) to 6 (smallest), used by the renderer
|
||||
* to pick the typography.
|
||||
*/
|
||||
@Immutable
|
||||
data class HeaderNode(
|
||||
val level: Int,
|
||||
val text: AnnotatedString,
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
|
||||
) : BlockNode
|
||||
|
||||
/**
|
||||
* A preformatted code block (`<pre>`). Rendered in a monospaced, horizontally
|
||||
* scrollable container. The text is kept raw: no inline styling is applied inside it.
|
||||
|
||||
+82
-13
@@ -62,6 +62,7 @@ import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
@@ -70,11 +71,13 @@ import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.linkify
|
||||
import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.ListNode
|
||||
@@ -157,6 +160,7 @@ internal fun BlockNode.lastBlockNode(): BlockNode? = when (this) {
|
||||
is ListNode -> this
|
||||
is QuoteNode -> this
|
||||
is CodeBlockNode -> this
|
||||
is HeaderNode -> this
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -179,7 +183,7 @@ private fun BlockNodeView(
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (node === context.lastBlockNode && node !is ParagraphNode) {
|
||||
if (node === context.lastBlockNode && node !is ParagraphNode && node !is HeaderNode) {
|
||||
SideEffect { context.onContentLayoutChange(ContentAvoidingLayoutData.NotOverlapping) }
|
||||
}
|
||||
|
||||
@@ -188,6 +192,7 @@ private fun BlockNodeView(
|
||||
is ParagraphNode -> {
|
||||
ParagraphView(node = node, context = context, modifier = modifier)
|
||||
}
|
||||
is HeaderNode -> HeaderView(node = node, context = context, modifier = modifier)
|
||||
is CodeBlockNode -> CodeBlockView(node = node, modifier = modifier)
|
||||
is QuoteNode -> QuoteView(node = node, context = context, modifier = modifier)
|
||||
is ListNode -> ListView(node = node, context = context, modifier = modifier)
|
||||
@@ -200,14 +205,6 @@ private fun ParagraphView(
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val linkColor = ElementTheme.colors.textLinkExternal
|
||||
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
|
||||
val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
|
||||
val styledText = remember(node.text, linkColor) {
|
||||
node.text.applyLinkStyles(linkColor = linkColor)
|
||||
}
|
||||
val inlineContent = rememberMentionInlineContent(node.inlineContent, context)
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
|
||||
// measured last line to the timeline.
|
||||
val measureLastLine = if (node === context.lastBlockNode) {
|
||||
@@ -215,21 +212,87 @@ private fun ParagraphView(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
TextBlock(
|
||||
text = node.text,
|
||||
inlineContent = node.inlineContent,
|
||||
textStyle = ElementTheme.typography.fontBodyMdRegular,
|
||||
context = context,
|
||||
modifier = modifier,
|
||||
onTextLayout = { measureLastLine?.invoke(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderView(
|
||||
node: HeaderNode,
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
|
||||
// measured last line to the timeline.
|
||||
val measureLastLine = if (node === context.lastBlockNode) {
|
||||
ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
TextBlock(
|
||||
text = node.text,
|
||||
inlineContent = node.inlineContent,
|
||||
textStyle = headerTextStyle(node.level),
|
||||
context = context,
|
||||
modifier = modifier,
|
||||
onTextLayout = { measureLastLine?.invoke(it) }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an inline-content block (a paragraph or a header) as a [Text] with [textStyle], applying
|
||||
* link styling, the inline-code background boxes, mention/image inline content and link taps.
|
||||
*/
|
||||
@Composable
|
||||
private fun TextBlock(
|
||||
text: AnnotatedString,
|
||||
inlineContent: ImmutableMap<String, MentionNodeContent>,
|
||||
textStyle: TextStyle,
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
) {
|
||||
val linkColor = ElementTheme.colors.textLinkExternal
|
||||
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
|
||||
val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
|
||||
val styledText = remember(text, linkColor) {
|
||||
text.linkify(SpanStyle(color = linkColor))
|
||||
.applyLinkStyles(linkColor = linkColor)
|
||||
}
|
||||
val resolvedInlineContent = rememberMentionInlineContent(inlineContent, context)
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
text = styledText,
|
||||
modifier = modifier
|
||||
.drawInlineCodeBackgrounds(styledText, layoutResult, codeBackgroundColor, codeBorderColor, LocalDensity.current)
|
||||
.linkTapHandler(styledText, layoutResult, context),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
style = textStyle,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
inlineContent = inlineContent,
|
||||
inlineContent = resolvedInlineContent,
|
||||
onTextLayout = { result ->
|
||||
layoutResult.value = result
|
||||
measureLastLine?.invoke(result)
|
||||
onTextLayout(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps an `<h1>`–`<h6>` [level] to a Compound typography style, largest first. */
|
||||
@Composable
|
||||
private fun headerTextStyle(level: Int): TextStyle = when (level) {
|
||||
// For safety reasons, use the same style for H1 and H2, to avoid people using H1 for 'shouting' spam that fills the timeline with huge text
|
||||
1, 2 -> ElementTheme.typography.fontHeadingLgBold
|
||||
3 -> ElementTheme.typography.fontHeadingMdBold
|
||||
4 -> ElementTheme.typography.fontHeadingSmMedium
|
||||
5 -> ElementTheme.typography.fontBodyLgMedium
|
||||
else -> ElementTheme.typography.fontBodyMdMedium
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBlockView(
|
||||
node: CodeBlockNode,
|
||||
@@ -411,7 +474,13 @@ private fun AnnotatedString.applyLinkStyles(linkColor: Color): AnnotatedString {
|
||||
if (linkRanges.isEmpty()) return this
|
||||
return buildAnnotatedString {
|
||||
append(this@applyLinkStyles)
|
||||
linkRanges.forEach { addStyle(SpanStyle(color = linkColor), it.start, it.end) }
|
||||
linkRanges.forEach { linkRange ->
|
||||
val containsColorSpan =
|
||||
spanStyles.any { colorRange -> colorRange.start == linkRange.start && colorRange.end == linkRange.end && colorRange.item.color == linkColor }
|
||||
if (!containsColorSpan) {
|
||||
addStyle(SpanStyle(color = linkColor), linkRange.start, linkRange.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withAnnotation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
@@ -25,6 +26,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.ListItemNode
|
||||
@@ -54,6 +56,7 @@ internal fun HtmlMessageContentPreview(
|
||||
internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<DocumentNode> {
|
||||
override val values: Sequence<DocumentNode>
|
||||
get() = sequenceOf(
|
||||
headers(),
|
||||
richParagraph(),
|
||||
inlineCode(),
|
||||
multilineInlineCode(),
|
||||
@@ -65,6 +68,7 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
mentionOwn(),
|
||||
nestedList(),
|
||||
nestedListWithComplexContents(),
|
||||
linkifiedParagraph(),
|
||||
)
|
||||
|
||||
private fun richParagraph() = document(
|
||||
@@ -82,6 +86,28 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
),
|
||||
)
|
||||
|
||||
private fun linkifiedParagraph() = document(
|
||||
paragraph(
|
||||
buildAnnotatedString {
|
||||
append("Hello me@matrix.org, check out element.io and and call +1 234 567 8900, then get in touch with ")
|
||||
appendInlineContent(MENTION_ID, "@alice:example.org")
|
||||
append(".\n\n")
|
||||
append("Also, ignore this link: ")
|
||||
val start = length
|
||||
append("https://matrix.org")
|
||||
addStringAnnotation(LINK_ANNOTATION_TAG, "https://matrix.org", start, length)
|
||||
append(" and this one: ")
|
||||
withAnnotation(INLINE_CODE_ANNOTATION_TAG, "") {
|
||||
append("https://element.io")
|
||||
}
|
||||
append(".")
|
||||
},
|
||||
inlineContent = persistentMapOf(
|
||||
MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun inlineCode() = document(
|
||||
paragraph(
|
||||
buildAnnotatedString {
|
||||
@@ -213,6 +239,16 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
),
|
||||
)
|
||||
|
||||
private fun headers() = document(
|
||||
HeaderNode(level = 1, text = AnnotatedString("Heading 1")),
|
||||
HeaderNode(level = 2, text = AnnotatedString("Heading 2")),
|
||||
HeaderNode(level = 3, text = AnnotatedString("Heading 3")),
|
||||
HeaderNode(level = 4, text = AnnotatedString("Heading 4")),
|
||||
HeaderNode(level = 5, text = AnnotatedString("Heading 5")),
|
||||
HeaderNode(level = 6, text = AnnotatedString("Heading 6")),
|
||||
paragraph(AnnotatedString("Body text under the headings.")),
|
||||
)
|
||||
|
||||
private fun document(vararg blocks: BlockNode) = DocumentNode(blocks.toList().toImmutableList())
|
||||
|
||||
private fun paragraph(
|
||||
|
||||
+26
-4
@@ -18,6 +18,7 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
@@ -30,6 +31,7 @@ import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import org.jsoup.nodes.Document
|
||||
@@ -81,12 +83,13 @@ class DefaultHtmlMessageParser(
|
||||
return blocks.toImmutableList()
|
||||
}
|
||||
|
||||
private fun parseBlock(element: Element): BlockNode? = when (element.tagName()) {
|
||||
private fun parseBlock(element: Element): BlockNode? = when (val tag = element.tagName()) {
|
||||
TAG_P -> buildParagraph(element.childNodes())
|
||||
TAG_PRE -> CodeBlockNode(code = element.wholeText().trimEnd('\n'))
|
||||
TAG_BLOCKQUOTE -> QuoteNode(children = parseBlocks(element))
|
||||
TAG_UL -> buildList(element, ordered = false)
|
||||
TAG_OL -> buildList(element, ordered = true)
|
||||
in HEADER_TAGS -> buildHeader(element, level = tag.removePrefix("h").toInt())
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -102,6 +105,17 @@ class DefaultHtmlMessageParser(
|
||||
|
||||
/** Builds a [ParagraphNode] from a run of inline [nodes], or `null` if it holds no content. */
|
||||
private fun buildParagraph(nodes: List<Node>): ParagraphNode? {
|
||||
return buildInlineContent(nodes)?.let { ParagraphNode(text = it.text, inlineContent = it.inlineContent) }
|
||||
}
|
||||
|
||||
/** Builds a [HeaderNode] of the given [level] from the inline content of [element], or `null` if empty. */
|
||||
private fun buildHeader(element: Element, level: Int): HeaderNode? {
|
||||
return buildInlineContent(element.childNodes())?.let {
|
||||
HeaderNode(level = level, text = it.text, inlineContent = it.inlineContent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildInlineContent(nodes: List<Node>): InlineContentBuilder.Result? {
|
||||
val builder = InlineContentBuilder()
|
||||
nodes.forEach { builder.append(it) }
|
||||
return builder.build()
|
||||
@@ -199,21 +213,29 @@ class DefaultHtmlMessageParser(
|
||||
}
|
||||
}
|
||||
|
||||
fun build(): ParagraphNode? {
|
||||
fun build(): Result? {
|
||||
val text = builder.toAnnotatedString()
|
||||
if (text.isBlank() && mentions.isEmpty()) return null
|
||||
return ParagraphNode(
|
||||
return Result(
|
||||
text = text,
|
||||
inlineContent = mentions.toImmutableMap(),
|
||||
)
|
||||
}
|
||||
|
||||
/** The flattened inline content of a block: styled text plus its inline-content placeholders. */
|
||||
inner class Result(
|
||||
val text: AnnotatedString,
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tags that start a new block-level node. Anything else is treated as inline content. */
|
||||
private fun Element.isBlockLevel(): Boolean = tagName() in BLOCK_TAGS
|
||||
|
||||
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL)
|
||||
private val HEADER_TAGS = setOf("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
|
||||
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL) + HEADER_TAGS
|
||||
|
||||
private const val TAG_P = "p"
|
||||
private const val TAG_PRE = "pre"
|
||||
|
||||
+19
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
|
||||
import io.element.android.libraries.htmlrenderer.api.ListNode
|
||||
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
|
||||
@@ -191,6 +192,24 @@ class DefaultHtmlMessageParserTest : RobolectricTest() {
|
||||
assertThat(paragraph.text.text).isEqualTo("Actual message")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headers become HeaderNodes with their level and inline content`() {
|
||||
val document = parse("<h1>Title</h1><h3>Subtitle with <strong>bold</strong></h3><h6>Small</h6>")
|
||||
assertThat(document.children).hasSize(3)
|
||||
|
||||
val h1 = document.children[0] as HeaderNode
|
||||
assertThat(h1.level).isEqualTo(1)
|
||||
assertThat(h1.text.text).isEqualTo("Title")
|
||||
|
||||
val h3 = document.children[1] as HeaderNode
|
||||
assertThat(h3.level).isEqualTo(3)
|
||||
assertThat(h3.text.text).isEqualTo("Subtitle with bold")
|
||||
|
||||
val h6 = document.children[2] as HeaderNode
|
||||
assertThat(h6.level).isEqualTo(6)
|
||||
assertThat(h6.text.text).isEqualTo("Small")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank input produces an empty document`() {
|
||||
val document = parse(" ")
|
||||
|
||||
+5
@@ -326,6 +326,11 @@ interface MatrixClient : ClientUrlContentFetcher {
|
||||
*/
|
||||
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
|
||||
|
||||
/**
|
||||
* Returns the latest [RoomInfo] of a room, if known to the client, or `null` if the room is unknown.
|
||||
*/
|
||||
suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?>
|
||||
|
||||
/**
|
||||
* Get a room info flow for a given room ID.
|
||||
* The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found.
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.core
|
||||
import io.element.android.libraries.androidutils.metadata.isInDebug
|
||||
import java.io.Serializable
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@JvmInline
|
||||
value class RoomId(val value: String) : Serializable {
|
||||
init {
|
||||
|
||||
+8
@@ -779,6 +779,14 @@ class RustMatrixClient(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerRoomListService.roomOrNull(roomId.value)?.use { room ->
|
||||
roomInfoMapper.map(room.roomInfo())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<RoomInfo>> {
|
||||
return mxCallbackFlow {
|
||||
val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null }
|
||||
|
||||
+4
@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import java.io.File
|
||||
import java.util.Optional
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class FakeMatrixClient(
|
||||
override val sessionId: SessionId = A_SESSION_ID,
|
||||
@@ -173,6 +174,7 @@ class FakeMatrixClient(
|
||||
var knockRoomLambda: (RoomIdOrAlias, String, List<String>) -> Result<RoomInfo?> = { _, _, _ ->
|
||||
Result.success(null)
|
||||
}
|
||||
var getRoomInfoLambda = { _: RoomId -> Result.success<RoomInfo?>(null) }
|
||||
var getRoomInfoFlowLambda = { _: RoomId ->
|
||||
flowOf<Optional<RoomInfo>>(Optional.empty())
|
||||
}
|
||||
@@ -379,6 +381,8 @@ class FakeMatrixClient(
|
||||
return Result.success(visitedRoomsId)
|
||||
}
|
||||
|
||||
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = getRoomInfoLambda(roomId)
|
||||
|
||||
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
|
||||
|
||||
var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->
|
||||
|
||||
+6
@@ -89,6 +89,12 @@ private object CustomHtmlToDomParser {
|
||||
"blockquote",
|
||||
"p",
|
||||
"br",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
// Add custom `mx-reply` tag, even if it's just to remove its contents from the plain text version of the message
|
||||
"mx-reply"
|
||||
)
|
||||
|
||||
+2
@@ -9,6 +9,7 @@
|
||||
package io.element.android.libraries.matrix.ui.model
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -20,6 +21,7 @@ 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
|
||||
|
||||
@Immutable
|
||||
data class InviteSender(
|
||||
val userId: UserId,
|
||||
val displayName: String,
|
||||
|
||||
@@ -18,4 +18,5 @@ dependencies {
|
||||
implementation(libs.appyx.core)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
}
|
||||
|
||||
+9
-24
@@ -8,38 +8,23 @@
|
||||
|
||||
package io.element.android.libraries.ui.common.nodes
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.node
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323
|
||||
*
|
||||
* @param buildContext the [BuildContext] of the Node.
|
||||
* @param state the [EmptyState] of the Node. Default value will render an empty node forever.
|
||||
*/
|
||||
fun emptyNode(
|
||||
buildContext: BuildContext,
|
||||
state: EmptyState = EmptyState(Duration.INFINITE),
|
||||
): Node = node(buildContext) { modifier ->
|
||||
EmptyView(modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyView(
|
||||
modifier: Modifier = Modifier,
|
||||
) = Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
)
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmptyViewPreview() = ElementPreview {
|
||||
EmptyView(Modifier)
|
||||
EmptyView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.ui.common.nodes
|
||||
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* @property delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for
|
||||
* placeholders which can stay on screen for a long time, or forever, else the application looks frozen.
|
||||
*/
|
||||
data class EmptyState(
|
||||
val delayBeforeShowingContent: Duration,
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.ui.common.nodes
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
open class EmptyStatePreviewParam : PreviewParameterProvider<EmptyState> {
|
||||
override val values: Sequence<EmptyState>
|
||||
get() = sequenceOf(
|
||||
EmptyState(Duration.INFINITE),
|
||||
EmptyState(1.seconds),
|
||||
)
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.ui.common.nodes
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.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.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.DelayedVisibility
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun EmptyView(
|
||||
state: EmptyState,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.delayBeforeShowingContent.isFinite()) {
|
||||
DelayedVisibility(duration = state.delayBeforeShowingContent) {
|
||||
LoadingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() = Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_loading),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmptyViewPreview(
|
||||
@PreviewParameter(EmptyStatePreviewParam::class) state: EmptyState
|
||||
) = ElementPreview {
|
||||
EmptyView(state = state)
|
||||
}
|
||||
Reference in New Issue
Block a user