Merge branch 'develop' of https://github.com/vector-im/element-x-android into dla/feature/connect_sdk_to_global_notifications_ui

This commit is contained in:
David Langley 2023-09-12 16:30:36 +01:00
commit c3fbac4678
686 changed files with 7212 additions and 2257 deletions

View file

@ -22,7 +22,6 @@ import android.graphics.Matrix
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.io.InputStream
import kotlin.math.min
fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
@ -32,13 +31,6 @@ fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int
}
}
/**
* Reads the EXIF metadata from the [inputStream] and rotates the current [Bitmap] to match it.
* @return The resulting [Bitmap] or `null` if no metadata was found.
*/
fun Bitmap.rotateToMetadataOrientation(inputStream: InputStream): Result<Bitmap> =
runCatching { rotateToMetadataOrientation(this, ExifInterface(inputStream)) }
/**
* Scales the current [Bitmap] to fit the ([maxWidth], [maxHeight]) bounds while keeping aspect ratio.
* @throws IllegalStateException if [maxWidth] or [maxHeight] <= 0.
@ -77,8 +69,11 @@ fun BitmapFactory.Options.calculateInSampleSize(desiredWidth: Int, desiredHeight
return inSampleSize
}
private fun rotateToMetadataOrientation(bitmap: Bitmap, exifInterface: ExifInterface): Bitmap {
val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
/**
* Decodes the [inputStream] into a [Bitmap] and applies the needed rotation based on [orientation].
* This orientation value must be one of `ExifInterface.ORIENTATION_*` constants.
*/
fun Bitmap.rotateToMetadataOrientation(orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
@ -94,8 +89,8 @@ private fun rotateToMetadataOrientation(bitmap: Bitmap, exifInterface: ExifInter
matrix.preRotate(90f)
matrix.preScale(-1f, 1f)
}
else -> return bitmap
else -> return this
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}

View file

@ -27,4 +27,18 @@ object VectorIcons {
val ReportContent = R.drawable.ic_report_content
val Groups = R.drawable.ic_groups
val Share = R.drawable.ic_share
val Poll = R.drawable.ic_poll
val PollEnd = R.drawable.ic_poll_end
val Bold = R.drawable.ic_bold
val BulletList = R.drawable.ic_bullet_list
val CodeBlock = R.drawable.ic_code_block
val IndentIncrease = R.drawable.ic_indent_increase
val IndentDecrease = R.drawable.ic_indent_decrease
val InlineCode = R.drawable.ic_inline_code
val Italic = R.drawable.ic_italic
val Link = R.drawable.ic_link
val NumberedList = R.drawable.ic_numbered_list
val Quote = R.drawable.ic_quote
val Strikethrough = R.drawable.ic_strikethrough
val Underline = R.drawable.ic_underline
}

View file

@ -44,6 +44,7 @@ import io.element.android.libraries.designsystem.text.withColoredPeriod
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.ForcedDarkElementTheme
@Composable
fun SunsetPage(
@ -53,9 +54,7 @@ fun SunsetPage(
modifier: Modifier = Modifier,
overallContent: @Composable () -> Unit,
) {
ElementTheme(
darkTheme = true
) {
ForcedDarkElementTheme(lightStatusBar = true) {
Box(
modifier = modifier.fillMaxSize()
) {

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.colors
import androidx.compose.ui.graphics.Color
data class AvatarColors(
val background: Color,
val foreground: Color,
)

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.colors
import androidx.collection.LruCache
import io.element.android.libraries.theme.colors.avatarColorsDark
import io.element.android.libraries.theme.colors.avatarColorsLight
object AvatarColorsProvider {
private val cache = LruCache<String, AvatarColors>(200)
private var currentThemeIsLight = true
fun provide(id: String, isLightTheme: Boolean): AvatarColors {
if (currentThemeIsLight != isLightTheme) {
currentThemeIsLight = isLightTheme
cache.evictAll()
}
val valueFromCache = cache.get(id)
return if (valueFromCache != null) {
valueFromCache
} else {
val colors = avatarColors(id, isLightTheme)
cache.put(id, colors)
colors
}
}
private fun avatarColors(id: String, isLightTheme: Boolean): AvatarColors {
val hash = id.toHash()
val colors = if (isLightTheme) {
avatarColorsLight[hash]
} else {
avatarColorsDark[hash]
}
return AvatarColors(
background = colors.first,
foreground = colors.second,
)
}
}
internal fun String.toHash(): Int {
return toList().sumOf { it.code } % avatarColorsLight.size
}

View file

@ -0,0 +1,564 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components
import android.graphics.Bitmap
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import android.text.TextPaint
import androidx.annotation.FloatRange
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import coil.imageLoader
import coil.request.DefaultRequestOptions
import coil.request.ImageRequest
import coil.size.Size
import com.airbnb.android.showkase.annotation.ShowkaseComposable
import com.vanniktech.blurhash.BlurHash
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Default bloom configuration values.
*/
object BloomDefaults {
/**
* Number of components to use with BlurHash to generate the blur effect.
* Larger values mean more detailed blurs.
*/
const val HASH_COMPONENTS = 5
/** Default bloom layers. */
@Composable
fun defaultLayers() = persistentListOf(
// Bottom layer
if (isSystemInDarkTheme()) {
BloomLayer(0.5f, BlendMode.Exclusion)
} else {
BloomLayer(0.2f, BlendMode.Hardlight)
},
// Top layer
BloomLayer(if (isSystemInDarkTheme()) 0.2f else 0.8f, BlendMode.Color),
)
}
/**
* Bloom layer configuration.
* @param alpha The alpha value to apply to the layer.
* @param blendMode The blend mode to apply to the layer.
*/
data class BloomLayer(
val alpha: Float,
val blendMode: BlendMode,
)
/**
* Bloom effect modifier. Applies a bloom effect to the component.
* @param hash The BlurHash to use as the bloom source.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param layerConfiguration The configuration for the bloom layers. If not specified the default layers configuration will be used.
* @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect.
*/
fun Modifier.bloom(
hash: String?,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
layerConfiguration: ImmutableList<BloomLayer>? = null,
bottomSoftEdgeColor: Color = background,
bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1f,
) = composed {
val defaultLayers = BloomDefaults.defaultLayers()
val layers = layerConfiguration ?: defaultLayers
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
if (hash == null) return@composed this
val hashedBitmap = remember(hash) {
BlurHash.decode(hash, BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)?.asImageBitmap()
} ?: return@composed this
val density = LocalDensity.current
val pixelSize = remember(blurSize, density) { blurSize.toIntSize(density) }
val clipToPixelSize = remember(clipToSize, density) { clipToSize.toIntSize(density) }
val bottomSoftEdgeHeightPixels = remember(bottomSoftEdgeHeight, density) { with(density) { bottomSoftEdgeHeight.roundToPx() } }
val isRTL = LocalLayoutDirection.current == LayoutDirection.Rtl
drawWithCache {
val dstSize = if (pixelSize != IntSize.Zero) {
pixelSize
} else {
IntSize(size.width.toInt(), size.height.toInt())
}
// Calculate where to place the center of the bloom effect
val centerOffset = if (offset.isSpecified) {
if (isRTL) {
IntOffset(
size.width.roundToInt() - offset.x.roundToPx(),
size.height.roundToInt() - offset.y.roundToPx(),
)
} else {
IntOffset(
offset.x.roundToPx(),
offset.y.roundToPx(),
)
}
} else {
IntOffset(
size.center.x.toInt(),
size.center.y.toInt(),
)
}
// Calculate the offset to draw the different layers and apply clipping
// This offset is applied to place the top left corner of the bloom effect
val layersOffset = if (offset.isSpecified) {
// Offsets the layers so the center of the bloom effect is at the provided offset value
IntOffset(
centerOffset.x - dstSize.width / 2,
centerOffset.y - dstSize.height / 2,
)
} else {
// Places the layers at the center of the component
IntOffset.Zero
}
val radius = max(dstSize.width, dstSize.height).toFloat() / 2
val circularGradientShader = RadialGradientShader(
centerOffset.toOffset(),
radius,
listOf(Color.Red, Color.Transparent),
listOf(0f, 1f)
)
val circularGradientBrush = ShaderBrush(circularGradientShader)
val bottomEdgeGradient = LinearGradientShader(
from = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeightPixels).toOffset(),
to = IntOffset(0, clipToPixelSize.height).toOffset(),
listOf(Color.Transparent, bottomSoftEdgeColor),
listOf(0f, 1f)
)
val bottomEdgeGradientBrush = ShaderBrush(bottomEdgeGradient)
onDrawBehind {
if (dstSize != IntSize.Zero) {
val circleClipPath = Path().apply {
addOval(Rect(centerOffset.toOffset(), radius - 1))
}
// Clip the external radius of bloom gradient too, otherwise we have a 1px border
clipPath(circleClipPath, clipOp = ClipOp.Intersect) {
// Draw the bloom layers
drawWithLayer {
// Clip rect to the provided size if needed
if (clipToPixelSize != IntSize.Zero) {
drawContext.canvas.clipRect(Rect(Offset.Zero, clipToPixelSize.toSize()), ClipOp.Intersect)
}
// Draw background color for blending
drawRect(background, size = pixelSize.toSize())
// Draw layers
for (layer in layers) {
drawImage(
hashedBitmap,
srcSize = IntSize(BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS),
dstSize = dstSize,
dstOffset = layersOffset,
alpha = layer.alpha * alpha,
blendMode = layer.blendMode,
)
}
// Mask the layers erasing the outer radius using the gradient brush
drawCircle(
circularGradientBrush,
radius,
centerOffset.toOffset(),
blendMode = BlendMode.DstIn
)
}
}
// Draw the bottom soft edge
drawRect(
bottomEdgeGradientBrush,
topLeft = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeight.roundToPx()).toOffset(),
size = IntSize(pixelSize.width, bottomSoftEdgeHeight.roundToPx()).toSize(),
alpha = bottomSoftEdgeAlpha
)
}
}
}
}
/**
* Bloom effect modifier for avatars. Applies a bloom effect to the component.
* @param avatarData The avatar data to use as the bloom source.
* If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used. If `null` is passed, no bloom effect will be applied.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect.
*/
fun Modifier.avatarBloom(
avatarData: AvatarData?,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
bottomSoftEdgeColor: Color = background,
bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1f,
) = composed {
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
avatarData ?: return@composed this
// Request the avatar contents to use as the bloom source
val context = LocalContext.current
val density = LocalDensity.current
if (avatarData.url != null) {
val painterRequest = remember(avatarData) {
ImageRequest.Builder(context)
.data(avatarData)
// Allow cache and default dispatchers
.defaults(DefaultRequestOptions())
// Needed to be able to read pixels from the Bitmap for the hash
.allowHardware(false)
// Reduce size so it loads faster for large avatars
.size(with(density) { Size(64.dp.roundToPx(), 64.dp.roundToPx()) })
.build()
}
// By making it saveable, we'll 'cache' the previous bloom effect until a new one is loaded
var blurHash by rememberSaveable(avatarData) { mutableStateOf<String?>(null) }
LaunchedEffect(avatarData) {
withContext(Dispatchers.IO) {
val drawable =
context.imageLoader.execute(painterRequest).drawable ?: return@withContext
val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@withContext
blurHash = BlurHash.encode(
bitmap,
BloomDefaults.HASH_COMPONENTS,
BloomDefaults.HASH_COMPONENTS
)
}
}
bloom(
hash = blurHash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha,
)
} else {
// There is no URL so we'll generate an avatar with the initials and use that as the bloom source
val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme)
val initialsBitmap = initialsBitmap(
width = avatarData.size.dp,
height = avatarData.size.dp,
text = avatarData.initial,
textColor = avatarColors.foreground,
backgroundColor = avatarColors.background,
)
val hash = remember(avatarData, avatarColors) {
BlurHash.encode(initialsBitmap.asAndroidBitmap(), BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
}
bloom(
hash = hash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
bottomSoftEdgeColor = bottomSoftEdgeColor,
bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha,
)
}
}
// Used to create a Bitmap version of the initials avatar
@Composable
private fun initialsBitmap(
text: String,
backgroundColor: Color,
textColor: Color,
width: Dp = 32.dp,
height: Dp = 32.dp,
): ImageBitmap = with(LocalDensity.current) {
val backgroundPaint = remember(backgroundColor) {
Paint().also { it.color = backgroundColor }
}
val resolver: FontFamily.Resolver = LocalFontFamilyResolver.current
val fontSize = remember { height.toSp() / 2 }
val typeface: Typeface = remember(resolver) {
resolver.resolve(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal,
)
}.value as Typeface
val textPaint = remember(textColor, typeface) {
TextPaint().apply {
color = textColor.toArgb()
textSize = fontSize.toPx()
this.typeface = typeface
}
}
val textMeasurer = rememberTextMeasurer()
val result = remember(text) { textMeasurer.measure(text, TextStyle.Default.copy(fontSize = fontSize)) }
val centerPx = remember(width, height) { IntOffset(width.roundToPx() / 2, height.roundToPx() / 2) }
remember(text, width, height, backgroundColor, textColor) {
val bitmap = Bitmap.createBitmap(width.roundToPx(), height.roundToPx(), Bitmap.Config.ARGB_8888).asImageBitmap()
androidx.compose.ui.graphics.Canvas(bitmap).also { canvas ->
canvas.drawCircle(centerPx.toOffset(), width.toPx() / 2, backgroundPaint)
canvas.nativeCanvas.drawText(text, centerPx.x.toFloat() - result.size.width/2, centerPx.y * 2f - result.size.height/2 - 4, textPaint)
}
bitmap
}
}
// Translates DP sizes into pixel sizes, taking into account unspecified values
private fun DpSize.toIntSize(density: Density) = with(density) {
if (isSpecified) {
IntSize(width.roundToPx(), height.roundToPx())
} else {
IntSize.Zero
}
}
/**
* Helper to draw to a canvas using layers. This allows us to apply clipping to those layers only.
*/
fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@DayNightPreviews
@ShowkaseComposable(group = PreviewGroup.Bloom)
@Composable
internal fun BloomPreview() {
val blurhash = "eePn{tI?xExEja}ooKWWodjtNJoKR,j@a|sBWpS3WDbGazoKWWWWj@"
var topAppBarHeight by remember { mutableIntStateOf(-1) }
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState)
ElementPreview {
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Box {
MediumTopAppBar(
modifier = Modifier
.onSizeChanged { size ->
topAppBarHeight = size.height
}
.bloom(
hash = blurhash,
background = ElementTheme.materialColors.background,
blurSize = DpSize(430.dp, 430.dp),
offset = DpOffset(24.dp, 24.dp),
clipToSize = if (topAppBarHeight > 0) DpSize(430.dp, topAppBarHeight.toDp()) else DpSize.Zero,
),
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Black.copy(alpha = 0.05f),
),
navigationIcon = {
Image(
modifier = Modifier
.padding(start = 8.dp)
.size(32.dp)
.clip(CircleShape),
painter = painterResource(id = R.drawable.sample_avatar),
contentScale = ContentScale.Crop,
contentDescription = null
)
},
actions = {
IconButton(onClick = {}) {
Icon(imageVector = Icons.Default.Share, contentDescription = null)
}
},
title = {
Text("Title")
},
scrollBehavior = scrollBehavior,
)
}
},
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
repeat(20) {
Text("Content", modifier = Modifier.padding(vertical = 20.dp))
}
}
}
}
}
class InitialsColorStateProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2, 3, 4, 5, 6, 7)
}
@DayNightPreviews
@Composable
@ShowkaseComposable(group = PreviewGroup.Bloom)
internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorStateProvider::class) color: Int) {
ElementPreview {
val avatarColors = AvatarColorsProvider.provide("$color", ElementTheme.isLightTheme)
val bitmap = initialsBitmap(text = "F", backgroundColor = avatarColors.background, textColor = avatarColors.foreground)
val hash = BlurHash.encode(bitmap.asAndroidBitmap(), BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
Box(
modifier = Modifier.size(256.dp)
.bloom(
hash = hash,
background = if (ElementTheme.isLightTheme) {
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
ElementTheme.materialColors.background
},
bottomSoftEdgeColor = ElementTheme.materialColors.background,
blurSize = DpSize(256.dp, 256.dp),
),
contentAlignment = Alignment.Center
) {
Image(
modifier = Modifier
.size(32.dp)
.clip(CircleShape),
painter = BitmapPainter(bitmap),
contentDescription = null
)
}
}
}

View file

@ -26,13 +26,13 @@ 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.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.debugPlaceholderAvatar
@ -87,20 +87,19 @@ private fun InitialsAvatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
) {
// Use temporary color for default avatar background
val avatarColor = ElementTheme.colors.bgActionPrimaryDisabled
val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme)
Box(
modifier.background(color = avatarColor),
modifier.background(color = avatarColors.background)
) {
val fontSize = avatarData.size.dp.toSp() / 2
val originalFont = ElementTheme.typography.fontBodyMdRegular
val originalFont = ElementTheme.typography.fontHeadingMdBold
val ratio = fontSize.value / originalFont.fontSize.value
val lineHeight = originalFont.lineHeight * ratio
Text(
modifier = Modifier.align(Alignment.Center),
text = avatarData.initial,
style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
color = Color.White,
color = avatarColors.foreground,
)
}
}

View file

@ -20,7 +20,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(28.dp),
CurrentUserTopBar(32.dp),
RoomHeader(96.dp),
RoomListItem(52.dp),
@ -42,4 +42,6 @@ enum class AvatarSize(val dp: Dp) {
RoomInviteItem(52.dp),
InviteSender(16.dp),
NotificationsOptIn(32.dp),
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.unit.dp
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.colors.avatarColorsLight
@DayNightPreviews
@Composable
internal fun UserAvatarPreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(avatarColorsLight.size) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Note: it's OK, since the hash of "0" is 0, the hash of "1" is 1, etc.
Avatar(anAvatarData(id = "$it"))
Text(text = "Color index $it")
}
}
}
}

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.preview
object PreviewGroup {
const val AppBars = "App Bars"
const val Avatars = "Avatars"
const val Bloom = "Bloom"
const val BottomSheets = "Bottom Sheets"
const val Buttons = "Buttons"
const val DateTimePickers = "DateTime pickers"

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.FloatingActionButtonDefaults
@ -38,7 +39,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
fun FloatingActionButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = FloatingActionButtonDefaults.shape,
shape: Shape = CircleShape, // FloatingActionButtonDefaults.shape,
containerColor: Color = FloatingActionButtonDefaults.containerColor,
contentColor: Color = contentColorFor(containerColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8.8,19C8.25,19 7.779,18.804 7.388,18.413C6.996,18.021 6.8,17.55 6.8,17V7C6.8,6.45 6.996,5.979 7.388,5.588C7.779,5.196 8.25,5 8.8,5H12.325C13.408,5 14.408,5.333 15.325,6C16.242,6.667 16.7,7.592 16.7,8.775C16.7,9.625 16.508,10.279 16.125,10.738C15.742,11.196 15.383,11.525 15.05,11.725C15.467,11.908 15.929,12.25 16.438,12.75C16.946,13.25 17.2,14 17.2,15C17.2,16.483 16.658,17.521 15.575,18.112C14.492,18.704 13.475,19 12.525,19H8.8ZM9.825,16.2H12.425C13.225,16.2 13.712,15.996 13.887,15.587C14.063,15.179 14.15,14.883 14.15,14.7C14.15,14.517 14.063,14.221 13.887,13.813C13.712,13.404 13.2,13.2 12.35,13.2H9.825V16.2ZM9.825,10.5H12.15C12.7,10.5 13.1,10.358 13.35,10.075C13.6,9.792 13.725,9.475 13.725,9.125C13.725,8.725 13.583,8.4 13.3,8.15C13.017,7.9 12.65,7.775 12.2,7.775H9.825V10.5Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M10,19C9.717,19 9.479,18.904 9.288,18.712C9.096,18.521 9,18.283 9,18C9,17.717 9.096,17.479 9.288,17.288C9.479,17.096 9.717,17 10,17H20C20.283,17 20.521,17.096 20.712,17.288C20.904,17.479 21,17.717 21,18C21,18.283 20.904,18.521 20.712,18.712C20.521,18.904 20.283,19 20,19H10ZM10,13C9.717,13 9.479,12.904 9.288,12.712C9.096,12.521 9,12.283 9,12C9,11.717 9.096,11.479 9.288,11.288C9.479,11.096 9.717,11 10,11H20C20.283,11 20.521,11.096 20.712,11.288C20.904,11.479 21,11.717 21,12C21,12.283 20.904,12.521 20.712,12.712C20.521,12.904 20.283,13 20,13H10ZM10,7C9.717,7 9.479,6.904 9.288,6.713C9.096,6.521 9,6.283 9,6C9,5.717 9.096,5.479 9.288,5.287C9.479,5.096 9.717,5 10,5H20C20.283,5 20.521,5.096 20.712,5.287C20.904,5.479 21,5.717 21,6C21,6.283 20.904,6.521 20.712,6.713C20.521,6.904 20.283,7 20,7H10ZM5,20C4.45,20 3.979,19.804 3.588,19.413C3.196,19.021 3,18.55 3,18C3,17.45 3.196,16.979 3.588,16.587C3.979,16.196 4.45,16 5,16C5.55,16 6.021,16.196 6.412,16.587C6.804,16.979 7,17.45 7,18C7,18.55 6.804,19.021 6.412,19.413C6.021,19.804 5.55,20 5,20ZM5,14C4.45,14 3.979,13.804 3.588,13.413C3.196,13.021 3,12.55 3,12C3,11.45 3.196,10.979 3.588,10.587C3.979,10.196 4.45,10 5,10C5.55,10 6.021,10.196 6.412,10.587C6.804,10.979 7,11.45 7,12C7,12.55 6.804,13.021 6.412,13.413C6.021,13.804 5.55,14 5,14ZM5,8C4.45,8 3.979,7.804 3.588,7.412C3.196,7.021 3,6.55 3,6C3,5.45 3.196,4.979 3.588,4.588C3.979,4.196 4.45,4 5,4C5.55,4 6.021,4.196 6.412,4.588C6.804,4.979 7,5.45 7,6C7,6.55 6.804,7.021 6.412,7.412C6.021,7.804 5.55,8 5,8Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8.825,12L10.3,10.525C10.5,10.325 10.6,10.092 10.6,9.825C10.6,9.558 10.5,9.325 10.3,9.125C10.1,8.925 9.863,8.825 9.587,8.825C9.313,8.825 9.075,8.925 8.875,9.125L6.7,11.3C6.6,11.4 6.529,11.508 6.488,11.625C6.446,11.742 6.425,11.867 6.425,12C6.425,12.133 6.446,12.258 6.488,12.375C6.529,12.492 6.6,12.6 6.7,12.7L8.875,14.875C9.075,15.075 9.313,15.175 9.587,15.175C9.863,15.175 10.1,15.075 10.3,14.875C10.5,14.675 10.6,14.442 10.6,14.175C10.6,13.908 10.5,13.675 10.3,13.475L8.825,12ZM15.175,12L13.7,13.475C13.5,13.675 13.4,13.908 13.4,14.175C13.4,14.442 13.5,14.675 13.7,14.875C13.9,15.075 14.137,15.175 14.413,15.175C14.688,15.175 14.925,15.075 15.125,14.875L17.3,12.7C17.4,12.6 17.471,12.492 17.513,12.375C17.554,12.258 17.575,12.133 17.575,12C17.575,11.867 17.554,11.742 17.513,11.625C17.471,11.508 17.4,11.4 17.3,11.3L15.125,9.125C15.025,9.025 14.913,8.95 14.788,8.9C14.663,8.85 14.538,8.825 14.413,8.825C14.288,8.825 14.163,8.85 14.038,8.9C13.913,8.95 13.8,9.025 13.7,9.125C13.5,9.325 13.4,9.558 13.4,9.825C13.4,10.092 13.5,10.325 13.7,10.525L15.175,12ZM5,21C4.45,21 3.979,20.804 3.588,20.413C3.196,20.021 3,19.55 3,19V5C3,4.45 3.196,3.979 3.588,3.588C3.979,3.196 4.45,3 5,3H19C19.55,3 20.021,3.196 20.413,3.588C20.804,3.979 21,4.45 21,5V19C21,19.55 20.804,20.021 20.413,20.413C20.021,20.804 19.55,21 19,21H5ZM5,19H19V5H5V19Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -15,12 +15,13 @@
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="#000000"
android:width="21dp"
android:height="22dp"
android:viewportWidth="21"
android:viewportHeight="22">
<path
android:pathData="M1.5,21.7C1.1,21.7 0.75,21.55 0.45,21.25C0.15,20.95 0,20.6 0,20.2V5.2C0,4.8 0.15,4.45 0.45,4.15C0.75,3.85 1.1,3.7 1.5,3.7H11.625L10.125,5.2H1.5V20.2H16.5V11.5L18,10V20.2C18,20.6 17.85,20.95 17.55,21.25C17.25,21.55 16.9,21.7 16.5,21.7H1.5ZM13.55,3.9L14.625,4.95L7.5,12.05V14.2H9.625L16.775,7.05L17.825,8.1L10.7,15.25C10.567,15.383 10.404,15.492 10.212,15.575C10.021,15.658 9.825,15.7 9.625,15.7H6.75C6.533,15.7 6.354,15.629 6.213,15.488C6.071,15.346 6,15.167 6,14.95V12.075C6,11.875 6.042,11.679 6.125,11.488C6.208,11.296 6.317,11.133 6.45,11L13.55,3.9ZM17.825,8.1L13.55,3.9L16.05,1.4C16.333,1.117 16.688,0.975 17.112,0.975C17.538,0.975 17.892,1.125 18.175,1.425L20.275,3.55C20.558,3.85 20.7,4.204 20.7,4.613C20.7,5.021 20.55,5.367 20.25,5.65L17.825,8.1Z"
android:fillColor="#ffffff"/>
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<path
android:fillColor="#ffffff"
android:pathData="M5,21.025C4.45,21.025 3.979,20.829 3.588,20.438C3.196,20.046 3,19.575 3,19.025V5.025C3,4.475 3.196,4.004 3.588,3.612C3.979,3.221 4.45,3.025 5,3.025H13.925L11.925,5.025H5V19.025H19V12.075L21,10.075V19.025C21,19.575 20.804,20.046 20.413,20.438C20.021,20.829 19.55,21.025 19,21.025H5ZM16.175,3.6L17.6,5L11,11.6V13.025H12.4L19.025,6.4L20.45,7.8L13.825,14.425C13.642,14.608 13.429,14.754 13.188,14.863C12.946,14.971 12.692,15.025 12.425,15.025H10C9.717,15.025 9.479,14.929 9.288,14.738C9.096,14.546 9,14.308 9,14.025V11.6C9,11.333 9.05,11.079 9.15,10.837C9.25,10.596 9.392,10.383 9.575,10.2L16.175,3.6ZM20.45,7.8L16.175,3.6L18.675,1.1C19.075,0.7 19.554,0.5 20.112,0.5C20.671,0.5 21.142,0.7 21.525,1.1L22.925,2.525C23.308,2.908 23.5,3.375 23.5,3.925C23.5,4.475 23.308,4.942 22.925,5.325L20.45,7.8Z" />
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,21C3.717,21 3.479,20.904 3.287,20.712C3.096,20.521 3,20.283 3,20C3,19.717 3.096,19.479 3.287,19.288C3.479,19.096 3.717,19 4,19H20C20.283,19 20.521,19.096 20.712,19.288C20.904,19.479 21,19.717 21,20C21,20.283 20.904,20.521 20.712,20.712C20.521,20.904 20.283,21 20,21H4ZM12,17C11.717,17 11.479,16.904 11.288,16.712C11.096,16.521 11,16.283 11,16C11,15.717 11.096,15.479 11.288,15.288C11.479,15.096 11.717,15 12,15H20C20.283,15 20.521,15.096 20.712,15.288C20.904,15.479 21,15.717 21,16C21,16.283 20.904,16.521 20.712,16.712C20.521,16.904 20.283,17 20,17H12ZM12,13C11.717,13 11.479,12.904 11.288,12.712C11.096,12.521 11,12.283 11,12C11,11.717 11.096,11.479 11.288,11.288C11.479,11.096 11.717,11 12,11H20C20.283,11 20.521,11.096 20.712,11.288C20.904,11.479 21,11.717 21,12C21,12.283 20.904,12.521 20.712,12.712C20.521,12.904 20.283,13 20,13H12ZM12,9C11.717,9 11.479,8.904 11.288,8.712C11.096,8.521 11,8.283 11,8C11,7.717 11.096,7.479 11.288,7.287C11.479,7.096 11.717,7 12,7H20C20.283,7 20.521,7.096 20.712,7.287C20.904,7.479 21,7.717 21,8C21,8.283 20.904,8.521 20.712,8.712C20.521,8.904 20.283,9 20,9H12ZM4,5C3.717,5 3.479,4.904 3.287,4.713C3.096,4.521 3,4.283 3,4C3,3.717 3.096,3.479 3.287,3.287C3.479,3.096 3.717,3 4,3H20C20.283,3 20.521,3.096 20.712,3.287C20.904,3.479 21,3.717 21,4C21,4.283 20.904,4.521 20.712,4.713C20.521,4.904 20.283,5 20,5H4ZM6.15,15.15L3.35,12.35C3.25,12.25 3.2,12.133 3.2,12C3.2,11.867 3.25,11.75 3.35,11.65L6.15,8.85C6.317,8.683 6.5,8.642 6.7,8.725C6.9,8.808 7,8.967 7,9.2V14.8C7,15.033 6.9,15.192 6.7,15.275C6.5,15.358 6.317,15.317 6.15,15.15Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,21C3.717,21 3.479,20.904 3.287,20.712C3.096,20.521 3,20.283 3,20C3,19.717 3.096,19.479 3.287,19.288C3.479,19.096 3.717,19 4,19H20C20.283,19 20.521,19.096 20.712,19.288C20.904,19.479 21,19.717 21,20C21,20.283 20.904,20.521 20.712,20.712C20.521,20.904 20.283,21 20,21H4ZM12,17C11.717,17 11.479,16.904 11.288,16.712C11.096,16.521 11,16.283 11,16C11,15.717 11.096,15.479 11.288,15.288C11.479,15.096 11.717,15 12,15H20C20.283,15 20.521,15.096 20.712,15.288C20.904,15.479 21,15.717 21,16C21,16.283 20.904,16.521 20.712,16.712C20.521,16.904 20.283,17 20,17H12ZM12,13C11.717,13 11.479,12.904 11.288,12.712C11.096,12.521 11,12.283 11,12C11,11.717 11.096,11.479 11.288,11.288C11.479,11.096 11.717,11 12,11H20C20.283,11 20.521,11.096 20.712,11.288C20.904,11.479 21,11.717 21,12C21,12.283 20.904,12.521 20.712,12.712C20.521,12.904 20.283,13 20,13H12ZM12,9C11.717,9 11.479,8.904 11.288,8.712C11.096,8.521 11,8.283 11,8C11,7.717 11.096,7.479 11.288,7.287C11.479,7.096 11.717,7 12,7H20C20.283,7 20.521,7.096 20.712,7.287C20.904,7.479 21,7.717 21,8C21,8.283 20.904,8.521 20.712,8.712C20.521,8.904 20.283,9 20,9H12ZM4,5C3.717,5 3.479,4.904 3.287,4.713C3.096,4.521 3,4.283 3,4C3,3.717 3.096,3.479 3.287,3.287C3.479,3.096 3.717,3 4,3H20C20.283,3 20.521,3.096 20.712,3.287C20.904,3.479 21,3.717 21,4C21,4.283 20.904,4.521 20.712,4.713C20.521,4.904 20.283,5 20,5H4ZM3.85,15.15C3.683,15.317 3.5,15.358 3.3,15.275C3.1,15.192 3,15.033 3,14.8V9.2C3,8.967 3.1,8.808 3.3,8.725C3.5,8.642 3.683,8.683 3.85,8.85L6.65,11.65C6.75,11.75 6.8,11.867 6.8,12C6.8,12.133 6.75,12.25 6.65,12.35L3.85,15.15Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14.958,5.621C15.116,5.092 14.816,4.534 14.287,4.375C13.758,4.217 13.201,4.517 13.042,5.046L9.042,18.379C8.883,18.908 9.184,19.466 9.713,19.625C10.242,19.783 10.799,19.483 10.958,18.954L14.958,5.621Z"
android:fillColor="#656D77"/>
<path
android:pathData="M5.974,7.232C5.549,6.878 4.919,6.936 4.565,7.36L1.232,11.36C0.923,11.731 0.923,12.269 1.232,12.64L4.565,16.64C4.919,17.065 5.549,17.122 5.974,16.768C6.398,16.415 6.455,15.784 6.102,15.36L3.302,12L6.102,8.64C6.455,8.216 6.398,7.585 5.974,7.232Z"
android:fillColor="#656D77"/>
<path
android:pathData="M18.027,7.232C18.451,6.878 19.081,6.936 19.435,7.36L22.768,11.36C23.077,11.731 23.077,12.269 22.768,12.64L19.435,16.64C19.081,17.065 18.451,17.122 18.027,16.768C17.602,16.415 17.545,15.784 17.898,15.36L20.698,12L17.898,8.64C17.545,8.216 17.602,7.585 18.027,7.232Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6.25,19C5.9,19 5.604,18.879 5.363,18.638C5.121,18.396 5,18.1 5,17.75C5,17.4 5.121,17.104 5.363,16.862C5.604,16.621 5.9,16.5 6.25,16.5H9L12,7.5H9.25C8.9,7.5 8.604,7.379 8.363,7.137C8.121,6.896 8,6.6 8,6.25C8,5.9 8.121,5.604 8.363,5.363C8.604,5.121 8.9,5 9.25,5H16.75C17.1,5 17.396,5.121 17.638,5.363C17.879,5.604 18,5.9 18,6.25C18,6.6 17.879,6.896 17.638,7.137C17.396,7.379 17.1,7.5 16.75,7.5H14.5L11.5,16.5H13.75C14.1,16.5 14.396,16.621 14.637,16.862C14.879,17.104 15,17.4 15,17.75C15,18.1 14.879,18.396 14.637,18.638C14.396,18.879 14.1,19 13.75,19H6.25Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,19.071C11.022,20.049 9.843,20.538 8.464,20.538C7.086,20.538 5.907,20.049 4.929,19.071C3.951,18.093 3.462,16.914 3.462,15.536C3.462,14.157 3.951,12.978 4.929,12L7.05,9.879C7.251,9.678 7.486,9.578 7.757,9.578C8.028,9.578 8.264,9.678 8.464,9.879C8.665,10.079 8.765,10.315 8.765,10.586C8.765,10.857 8.665,11.093 8.464,11.293L6.343,13.414C5.754,14.003 5.459,14.711 5.459,15.536C5.459,16.361 5.754,17.068 6.343,17.657C6.932,18.246 7.639,18.541 8.464,18.541C9.289,18.541 9.997,18.246 10.586,17.657L12.707,15.536C12.907,15.335 13.143,15.235 13.414,15.235C13.685,15.235 13.921,15.335 14.121,15.536C14.322,15.736 14.422,15.972 14.422,16.243C14.422,16.514 14.322,16.749 14.121,16.95L12,19.071ZM10.586,14.828C10.385,15.029 10.15,15.129 9.879,15.129C9.608,15.129 9.372,15.029 9.172,14.828C8.971,14.628 8.871,14.392 8.871,14.121C8.871,13.85 8.971,13.615 9.172,13.414L13.414,9.172C13.615,8.971 13.85,8.871 14.121,8.871C14.392,8.871 14.628,8.971 14.828,9.172C15.029,9.372 15.129,9.608 15.129,9.879C15.129,10.15 15.029,10.385 14.828,10.586L10.586,14.828ZM16.95,14.121C16.749,14.322 16.514,14.422 16.243,14.422C15.972,14.422 15.736,14.322 15.535,14.121C15.335,13.921 15.235,13.685 15.235,13.414C15.235,13.143 15.335,12.908 15.535,12.707L17.657,10.586C18.246,9.997 18.541,9.289 18.541,8.465C18.541,7.64 18.246,6.932 17.657,6.343C17.068,5.754 16.361,5.459 15.535,5.459C14.711,5.459 14.003,5.754 13.414,6.343L11.293,8.465C11.092,8.665 10.857,8.765 10.586,8.765C10.315,8.765 10.079,8.665 9.879,8.465C9.678,8.264 9.578,8.028 9.578,7.757C9.578,7.486 9.678,7.251 9.879,7.05L12,4.929C12.978,3.951 14.157,3.462 15.535,3.462C16.914,3.462 18.093,3.951 19.071,4.929C20.049,5.907 20.538,7.086 20.538,8.465C20.538,9.843 20.049,11.022 19.071,12L16.95,14.121Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3.75,22C3.533,22 3.354,21.929 3.213,21.788C3.071,21.646 3,21.467 3,21.25C3,21.033 3.071,20.854 3.213,20.712C3.354,20.571 3.533,20.5 3.75,20.5H5.5V19.75H4.75C4.533,19.75 4.354,19.679 4.213,19.538C4.071,19.396 4,19.217 4,19C4,18.783 4.071,18.604 4.213,18.462C4.354,18.321 4.533,18.25 4.75,18.25H5.5V17.5H3.75C3.533,17.5 3.354,17.429 3.213,17.288C3.071,17.146 3,16.967 3,16.75C3,16.533 3.071,16.354 3.213,16.212C3.354,16.071 3.533,16 3.75,16H6C6.283,16 6.521,16.096 6.713,16.288C6.904,16.479 7,16.717 7,17V18C7,18.283 6.904,18.521 6.713,18.712C6.521,18.904 6.283,19 6,19C6.283,19 6.521,19.096 6.713,19.288C6.904,19.479 7,19.717 7,20V21C7,21.283 6.904,21.521 6.713,21.712C6.521,21.904 6.283,22 6,22H3.75ZM3.75,15C3.533,15 3.354,14.929 3.213,14.788C3.071,14.646 3,14.467 3,14.25V12.25C3,11.967 3.096,11.729 3.287,11.538C3.479,11.346 3.717,11.25 4,11.25H5.5V10.5H3.75C3.533,10.5 3.354,10.429 3.213,10.288C3.071,10.146 3,9.967 3,9.75C3,9.533 3.071,9.354 3.213,9.212C3.354,9.071 3.533,9 3.75,9H6C6.283,9 6.521,9.096 6.713,9.288C6.904,9.479 7,9.717 7,10V11.75C7,12.033 6.904,12.271 6.713,12.462C6.521,12.654 6.283,12.75 6,12.75H4.5V13.5H6.25C6.467,13.5 6.646,13.571 6.787,13.712C6.929,13.854 7,14.033 7,14.25C7,14.467 6.929,14.646 6.787,14.788C6.646,14.929 6.467,15 6.25,15H3.75ZM5.25,8C5.033,8 4.854,7.929 4.713,7.787C4.571,7.646 4.5,7.467 4.5,7.25V3.5H3.75C3.533,3.5 3.354,3.429 3.213,3.287C3.071,3.146 3,2.967 3,2.75C3,2.533 3.071,2.354 3.213,2.213C3.354,2.071 3.533,2 3.75,2H5.25C5.467,2 5.646,2.071 5.787,2.213C5.929,2.354 6,2.533 6,2.75V7.25C6,7.467 5.929,7.646 5.787,7.787C5.646,7.929 5.467,8 5.25,8ZM10,19C9.717,19 9.479,18.904 9.288,18.712C9.096,18.521 9,18.283 9,18C9,17.717 9.096,17.479 9.288,17.288C9.479,17.096 9.717,17 10,17H20C20.283,17 20.521,17.096 20.712,17.288C20.904,17.479 21,17.717 21,18C21,18.283 20.904,18.521 20.712,18.712C20.521,18.904 20.283,19 20,19H10ZM10,13C9.717,13 9.479,12.904 9.288,12.712C9.096,12.521 9,12.283 9,12C9,11.717 9.096,11.479 9.288,11.288C9.479,11.096 9.717,11 10,11H20C20.283,11 20.521,11.096 20.712,11.288C20.904,11.479 21,11.717 21,12C21,12.283 20.904,12.521 20.712,12.712C20.521,12.904 20.283,13 20,13H10ZM10,7C9.717,7 9.479,6.904 9.288,6.713C9.096,6.521 9,6.283 9,6C9,5.717 9.096,5.479 9.288,5.287C9.479,5.096 9.717,5 10,5H20C20.283,5 20.521,5.096 20.712,5.287C20.904,5.479 21,5.717 21,6C21,6.283 20.904,6.521 20.712,6.713C20.521,6.904 20.283,7 20,7H10Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M7.333,15.583C7.593,15.583 7.811,15.495 7.986,15.32C8.162,15.144 8.25,14.926 8.25,14.667V10.083C8.25,9.824 8.162,9.606 7.986,9.43C7.811,9.255 7.593,9.167 7.333,9.167C7.074,9.167 6.856,9.255 6.68,9.43C6.505,9.606 6.417,9.824 6.417,10.083V14.667C6.417,14.926 6.505,15.144 6.68,15.32C6.856,15.495 7.074,15.583 7.333,15.583ZM11,15.583C11.26,15.583 11.477,15.495 11.653,15.32C11.829,15.144 11.917,14.926 11.917,14.667V7.333C11.917,7.074 11.829,6.856 11.653,6.68C11.477,6.505 11.26,6.417 11,6.417C10.74,6.417 10.523,6.505 10.347,6.68C10.171,6.856 10.083,7.074 10.083,7.333V14.667C10.083,14.926 10.171,15.144 10.347,15.32C10.523,15.495 10.74,15.583 11,15.583ZM14.667,15.583C14.926,15.583 15.144,15.495 15.32,15.32C15.495,15.144 15.583,14.926 15.583,14.667V12.833C15.583,12.574 15.495,12.356 15.32,12.18C15.144,12.005 14.926,11.917 14.667,11.917C14.407,11.917 14.189,12.005 14.014,12.18C13.838,12.356 13.75,12.574 13.75,12.833V14.667C13.75,14.926 13.838,15.144 14.014,15.32C14.189,15.495 14.407,15.583 14.667,15.583ZM4.583,19.25C4.079,19.25 3.648,19.07 3.289,18.712C2.93,18.352 2.75,17.921 2.75,17.417V4.583C2.75,4.079 2.93,3.648 3.289,3.289C3.648,2.93 4.079,2.75 4.583,2.75H17.417C17.921,2.75 18.352,2.93 18.712,3.289C19.07,3.648 19.25,4.079 19.25,4.583V17.417C19.25,17.921 19.07,18.352 18.712,18.712C18.352,19.07 17.921,19.25 17.417,19.25H4.583ZM4.583,17.417H17.417V4.583H4.583V17.417Z"
android:fillColor="#1B1D22"/>
</vector>

View file

@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M17.148,7.065L20.815,3.399C21.173,3.041 21.173,2.46 20.815,2.102C20.457,1.744 19.876,1.744 19.518,2.102L16.5,5.121L15.315,3.936C14.957,3.578 14.377,3.578 14.019,3.936C13.66,4.294 13.66,4.874 14.019,5.232L15.852,7.065C16.21,7.423 16.79,7.423 17.148,7.065Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M19.25,17.417V9.771C18.677,9.974 18.059,10.084 17.417,10.084V17.417H4.583V4.584L11.917,4.584C11.917,3.941 12.027,3.324 12.23,2.751H4.583C4.079,2.751 3.648,2.93 3.289,3.289C2.93,3.648 2.75,4.08 2.75,4.584V17.417C2.75,17.921 2.93,18.353 3.289,18.712C3.648,19.071 4.079,19.251 4.583,19.251H17.417C17.921,19.251 18.352,19.071 18.712,18.712C19.07,18.353 19.25,17.921 19.25,17.417Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M7.333,15.584C7.593,15.584 7.811,15.496 7.986,15.32C8.162,15.145 8.25,14.927 8.25,14.667V10.084C8.25,9.824 8.162,9.607 7.986,9.431C7.811,9.255 7.593,9.167 7.333,9.167C7.074,9.167 6.856,9.255 6.68,9.431C6.505,9.607 6.417,9.824 6.417,10.084V14.667C6.417,14.927 6.505,15.145 6.68,15.32C6.856,15.496 7.074,15.584 7.333,15.584Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M11,15.584C11.26,15.584 11.477,15.496 11.653,15.32C11.829,15.145 11.917,14.927 11.917,14.667V7.334C11.917,7.074 11.829,6.857 11.653,6.681C11.477,6.505 11.26,6.417 11,6.417C10.74,6.417 10.523,6.505 10.347,6.681C10.171,6.857 10.083,7.074 10.083,7.334V14.667C10.083,14.927 10.171,15.145 10.347,15.32C10.523,15.496 10.74,15.584 11,15.584Z"
android:fillColor="#1B1D22"/>
<path
android:pathData="M14.667,15.584C14.926,15.584 15.144,15.496 15.32,15.32C15.495,15.145 15.583,14.927 15.583,14.667V12.834C15.583,12.574 15.495,12.357 15.32,12.181C15.144,12.005 14.926,11.917 14.667,11.917C14.407,11.917 14.189,12.005 14.014,12.181C13.838,12.357 13.75,12.574 13.75,12.834V14.667C13.75,14.927 13.838,15.145 14.014,15.32C14.189,15.496 14.407,15.584 14.667,15.584Z"
android:fillColor="#1B1D22"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.719,4.34C4.813,3.698 4.353,3.104 3.691,3.012C3.028,2.92 2.415,3.366 2.32,4.008L1.512,9.486C1.418,10.128 1.878,10.723 2.54,10.814C3.203,10.906 3.816,10.46 3.911,9.818L4.719,4.34Z"
android:fillColor="#656D77"/>
<path
android:pathData="M16.834,14.514C16.928,13.872 16.468,13.277 15.806,13.186C15.144,13.094 14.53,13.54 14.435,14.182L13.627,19.66C13.533,20.302 13.993,20.896 14.656,20.988C15.318,21.08 15.932,20.634 16.026,19.992L16.834,14.514Z"
android:fillColor="#656D77"/>
<path
android:pathData="M9.318,3.009C9.983,3.086 10.456,3.671 10.376,4.315L10.354,4.49C10.34,4.602 10.319,4.763 10.293,4.961C10.242,5.358 10.17,5.902 10.088,6.496C9.927,7.667 9.72,9.075 9.553,9.882C9.422,10.518 8.784,10.931 8.128,10.803C7.472,10.676 7.046,10.058 7.177,9.422C7.326,8.701 7.523,7.37 7.687,6.185C7.767,5.599 7.838,5.061 7.889,4.669C7.915,4.473 7.935,4.314 7.949,4.204L7.97,4.034C8.05,3.39 8.654,2.931 9.318,3.009Z"
android:fillColor="#656D77"/>
<path
android:pathData="M22.488,14.514C22.582,13.872 22.122,13.277 21.46,13.186C20.797,13.094 20.184,13.54 20.089,14.182L19.281,19.66C19.187,20.302 19.647,20.896 20.309,20.988C20.972,21.08 21.585,20.634 21.68,19.992L22.488,14.514Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,25 @@
<!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15.05 16.46C13.79 17.43 12.2 18 10.5 18 6.36 18 3 14.64 3 10.5 3 6.36 6.36 3 10.5 3c4.14 0 7.5 3.36 7.5 7.5 0 1.71-0.57 3.29-1.54 4.55l3.25 3.24c0.39 0.4 0.39 1.03 0 1.42-0.4 0.39-1.03 0.39-1.42 0l-3.24-3.25ZM16 10.5C16 7.46 13.54 5 10.5 5S5 7.46 5 10.5 7.46 16 10.5 16s5.5-2.46 5.5-5.5Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.15,20C10.883,20 9.758,19.625 8.775,18.875C7.792,18.125 7.083,17.1 6.65,15.8L8.85,14.85C9.083,15.65 9.488,16.308 10.063,16.825C10.637,17.342 11.35,17.6 12.2,17.6C12.9,17.6 13.533,17.433 14.1,17.1C14.667,16.767 14.95,16.233 14.95,15.5C14.95,15.2 14.892,14.925 14.775,14.675C14.658,14.425 14.5,14.2 14.3,14H17.1C17.183,14.233 17.246,14.471 17.288,14.712C17.329,14.954 17.35,15.217 17.35,15.5C17.35,16.933 16.837,18.042 15.813,18.825C14.788,19.608 13.567,20 12.15,20ZM3,12C2.717,12 2.479,11.904 2.287,11.712C2.096,11.521 2,11.283 2,11C2,10.717 2.096,10.479 2.287,10.288C2.479,10.096 2.717,10 3,10H21C21.283,10 21.521,10.096 21.712,10.288C21.904,10.479 22,10.717 22,11C22,11.283 21.904,11.521 21.712,11.712C21.521,11.904 21.283,12 21,12H3ZM12.05,3.85C13.15,3.85 14.113,4.121 14.938,4.662C15.762,5.204 16.4,6.033 16.85,7.15L14.65,8.125C14.5,7.642 14.221,7.208 13.813,6.825C13.404,6.442 12.833,6.25 12.1,6.25C11.417,6.25 10.85,6.404 10.4,6.713C9.95,7.021 9.7,7.45 9.65,8H7.25C7.283,6.85 7.738,5.871 8.613,5.063C9.488,4.254 10.633,3.85 12.05,3.85Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,21C5.717,21 5.479,20.904 5.287,20.712C5.096,20.521 5,20.283 5,20C5,19.717 5.096,19.479 5.287,19.288C5.479,19.096 5.717,19 6,19H18C18.283,19 18.521,19.096 18.712,19.288C18.904,19.479 19,19.717 19,20C19,20.283 18.904,20.521 18.712,20.712C18.521,20.904 18.283,21 18,21H6ZM12,17C10.317,17 9.008,16.475 8.075,15.425C7.142,14.375 6.675,12.983 6.675,11.25V4.275C6.675,3.925 6.804,3.625 7.063,3.375C7.321,3.125 7.625,3 7.975,3C8.325,3 8.625,3.125 8.875,3.375C9.125,3.625 9.25,3.925 9.25,4.275V11.4C9.25,12.333 9.483,13.092 9.95,13.675C10.417,14.258 11.1,14.55 12,14.55C12.9,14.55 13.583,14.258 14.05,13.675C14.517,13.092 14.75,12.333 14.75,11.4V4.275C14.75,3.925 14.879,3.625 15.137,3.375C15.396,3.125 15.7,3 16.05,3C16.4,3 16.7,3.125 16.95,3.375C17.2,3.625 17.325,3.925 17.325,4.275V11.25C17.325,12.983 16.858,14.375 15.925,15.425C14.992,16.475 13.683,17 12,17Z"
android:fillColor="#656D77"/>
</vector>

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.colors
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.theme.colors.avatarColorsDark
import io.element.android.libraries.theme.colors.avatarColorsLight
import org.junit.Test
class AvatarColorsTest {
@Test
fun `ensure the size of the avatar color are equal for light and dark theme`() {
assertThat(avatarColorsDark.size).isEqualTo(avatarColorsLight.size)
}
@Test
fun `compute string hash`() {
assertThat("@alice:domain.org".toHash()).isEqualTo(6)
assertThat("@bob:domain.org".toHash()).isEqualTo(3)
assertThat("@charlie:domain.org".toHash()).isEqualTo(0)
}
@Test
fun `compute string hash reverse`() {
assertThat("0".toHash()).isEqualTo(0)
assertThat("1".toHash()).isEqualTo(1)
assertThat("2".toHash()).isEqualTo(2)
assertThat("3".toHash()).isEqualTo(3)
assertThat("4".toHash()).isEqualTo(4)
assertThat("5".toHash()).isEqualTo(5)
assertThat("6".toHash()).isEqualTo(6)
assertThat("7".toHash()).isEqualTo(7)
}
}

View file

@ -28,7 +28,8 @@ interface FeatureFlagService {
* @param feature the feature to enable or disable
* @param enabled true to enable the feature
*
* @return true if the method succeeds, ie if a RuntimeFeatureFlagProvider is registered
* @return true if the method succeeds, ie if a [io.element.android.libraries.featureflag.impl.MutableFeatureFlagProvider]
* is registered
*/
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean
}

View file

@ -16,24 +16,32 @@
package io.element.android.libraries.featureflag.api
/**
* To enable or disable a FeatureFlags, change the `defaultValue` value.
* Warning: to enable a flag for the release app, you MUST update the file
* [io.element.android.libraries.featureflag.impl.StaticFeatureFlagProvider]
*/
enum class FeatureFlags(
override val key: String,
override val title: String,
override val description: String? = null,
override val defaultValue: Boolean = true
override val defaultValue: Boolean
) : Feature {
LocationSharing(
key = "feature.locationsharing",
title = "Allow user to share location",
defaultValue = true,
),
Polls(
key = "feature.polls",
title = "Polls",
description = "Create poll and render poll events in the timeline",
defaultValue = false,
defaultValue = true,
),
NotificationSettings(
key = "feature.notificationsettings",
title = "Show notification settings",
)
// Do not forget to edit StaticFeatureFlagProvider when enabling the feature.
defaultValue = false,
),
}

View file

@ -38,7 +38,7 @@ class DefaultFeatureFlagService @Inject constructor(
}
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {
return providers.filterIsInstance(RuntimeFeatureFlagProvider::class.java)
return providers.filterIsInstance(MutableFeatureFlagProvider::class.java)
.sortedBy(FeatureFlagProvider::priority)
.firstOrNull()
?.setFeatureEnabled(feature, enabled)

View file

@ -18,6 +18,6 @@ package io.element.android.libraries.featureflag.impl
import io.element.android.libraries.featureflag.api.Feature
interface RuntimeFeatureFlagProvider : FeatureFlagProvider {
interface MutableFeatureFlagProvider : FeatureFlagProvider {
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean)
}

View file

@ -30,12 +30,13 @@ import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_featureflag")
class PreferencesFeatureFlagProvider @Inject constructor(@ApplicationContext context: Context) : RuntimeFeatureFlagProvider {
/**
* Note: this will be used only in the nightly and in the debug build.
*/
class PreferencesFeatureFlagProvider @Inject constructor(@ApplicationContext context: Context) : MutableFeatureFlagProvider {
private val store = context.dataStore
override val priority: Int
get() = MEDIUM_PRIORITY
override val priority = MEDIUM_PRIORITY
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) {
store.edit { prefs ->

View file

@ -20,17 +20,20 @@ import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlags
import javax.inject.Inject
class BuildtimeFeatureFlagProvider @Inject constructor() :
/**
* This provider is used for release build.
* This is the place to enable or disable feature for the release build.
*/
class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlagProvider {
override val priority: Int
get() = LOW_PRIORITY
override val priority = LOW_PRIORITY
override suspend fun isFeatureEnabled(feature: Feature): Boolean {
return if (feature is FeatureFlags) {
when (feature) {
when(feature) {
FeatureFlags.LocationSharing -> true
FeatureFlags.Polls -> false
FeatureFlags.Polls -> true
FeatureFlags.NotificationSettings -> false
}
} else {

View file

@ -22,7 +22,7 @@ import dagger.Provides
import dagger.multibindings.ElementsIntoSet
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.featureflag.impl.BuildtimeFeatureFlagProvider
import io.element.android.libraries.featureflag.impl.StaticFeatureFlagProvider
import io.element.android.libraries.featureflag.impl.FeatureFlagProvider
import io.element.android.libraries.featureflag.impl.PreferencesFeatureFlagProvider
@ -35,14 +35,18 @@ object FeatureFlagModule {
@ElementsIntoSet
fun providesFeatureFlagProvider(
buildType: BuildType,
runtimeFeatureFlagProvider: PreferencesFeatureFlagProvider,
buildtimeFeatureFlagProvider: BuildtimeFeatureFlagProvider,
mutableFeatureFlagProvider: PreferencesFeatureFlagProvider,
staticFeatureFlagProvider: StaticFeatureFlagProvider,
): Set<FeatureFlagProvider> {
val providers = HashSet<FeatureFlagProvider>()
if (buildType == BuildType.RELEASE) {
providers.add(buildtimeFeatureFlagProvider)
} else {
providers.add(runtimeFeatureFlagProvider)
when (buildType) {
BuildType.RELEASE -> {
providers.add(staticFeatureFlagProvider)
}
BuildType.NIGHTLY,
BuildType.DEBUG -> {
providers.add(mutableFeatureFlagProvider)
}
}
return providers
}

View file

@ -39,7 +39,7 @@ class DefaultFeatureFlagServiceTest {
@Test
fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest {
val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0)
val featureFlagProvider = FakeMutableFeatureFlagProvider(0)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider))
val result = featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(result).isEqualTo(true)
@ -47,7 +47,7 @@ class DefaultFeatureFlagServiceTest {
@Test
fun `given service with a runtime provider and feature enabled when feature is checked then it returns the correct value`() = runTest {
val featureFlagProvider = FakeRuntimeFeatureFlagProvider(0)
val featureFlagProvider = FakeMutableFeatureFlagProvider(0)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider))
featureFlagService.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(true)
@ -57,11 +57,11 @@ class DefaultFeatureFlagServiceTest {
@Test
fun `given service with 2 runtime providers when feature is checked then it uses the priority correctly`() = runTest {
val lowPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(LOW_PRIORITY)
val highPriorityfeatureFlagProvider = FakeRuntimeFeatureFlagProvider(HIGH_PRIORITY)
val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityfeatureFlagProvider, highPriorityfeatureFlagProvider))
lowPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, false)
highPriorityfeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, true)
val lowPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(LOW_PRIORITY)
val highPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(HIGH_PRIORITY)
val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityFeatureFlagProvider, highPriorityFeatureFlagProvider))
lowPriorityFeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, false)
highPriorityFeatureFlagProvider.setFeatureEnabled(FeatureFlags.LocationSharing, true)
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)).isEqualTo(true)
}
}

View file

@ -18,7 +18,7 @@ package io.element.android.libraries.featureflag.impl
import io.element.android.libraries.featureflag.api.Feature
class FakeRuntimeFeatureFlagProvider(override val priority: Int) : RuntimeFeatureFlagProvider {
class FakeMutableFeatureFlagProvider(override val priority: Int) : MutableFeatureFlagProvider {
private val enabledFeatures = HashMap<String, Boolean>()

View file

@ -85,11 +85,11 @@ interface MatrixRoom : Closeable {
suspend fun userAvatarUrl(userId: UserId): Result<String?>
suspend fun sendMessage(message: String): Result<Unit>
suspend fun sendMessage(body: String, htmlBody: String): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit>
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit>
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit>
suspend fun redactEvent(eventId: EventId, reason: String? = null): Result<Unit>

View file

@ -32,6 +32,11 @@ interface RoomListService {
data object Terminated : State()
}
sealed class SyncIndicator {
data object Show : SyncIndicator()
data object Hide : SyncIndicator()
}
/**
* returns a [RoomList] object of all rooms we want to display.
* This will exclude some rooms like the invites, or spaces.
@ -49,6 +54,11 @@ interface RoomListService {
*/
fun updateAllRoomsVisibleRange(range: IntRange)
/**
* The sync indicator as a flow.
*/
val syncIndicator: StateFlow<SyncIndicator>
/**
* The state of the service as a flow.
*/

View file

@ -19,24 +19,27 @@ package io.element.android.libraries.matrix.api.tracing
data class TracingFilterConfiguration(
val overrides: Map<Target, LogLevel> = emptyMap(),
) {
private val defaultLogLevel = LogLevel.INFO
// Order should matters
private val targetsToLogLevel: MutableMap<Target, LogLevel> = mutableMapOf(
Target.COMMON to LogLevel.Info,
Target.HYPER to LogLevel.Warn,
Target.MATRIX_SDK_CRYPTO to LogLevel.Debug,
Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.Debug,
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.Trace,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.Trace,
Target.MATRIX_SDK_UI_TIMELINE to LogLevel.Info,
private val targetsToLogLevel: Map<Target, LogLevel> = mapOf(
Target.HYPER to LogLevel.WARN,
Target.MATRIX_SDK_CRYPTO to LogLevel.DEBUG,
Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.DEBUG,
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.TRACE,
)
fun getLogLevel(target: Target): LogLevel {
return overrides[target] ?: targetsToLogLevel[target] ?: defaultLogLevel
}
val filter: String
get() {
overrides.forEach { (target, logLevel) ->
targetsToLogLevel[target] = logLevel
val fullMap = Target.values().associateWith {
overrides[it] ?: targetsToLogLevel[it] ?: defaultLogLevel
}
return targetsToLogLevel.map {
return fullMap.map {
if (it.key.filter.isEmpty()) {
it.value.filter
} else {
@ -53,31 +56,32 @@ enum class Target(open val filter: String) {
MATRIX_SDK_FFI("matrix_sdk_ffi"),
MATRIX_SDK_UNIFFI_API("matrix_sdk_ffi::uniffi_api"),
MATRIX_SDK_CRYPTO("matrix_sdk_crypto"),
MATRIX_SDK("matrix_sdk"),
MATRIX_SDK_HTTP_CLIENT("matrix_sdk::http_client"),
MATRIX_SDK_CLIENT("matrix_sdk::client"),
MATRIX_SDK_OIDC("matrix_sdk::oidc"),
MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"),
MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"),
MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"),
}
sealed class LogLevel(val filter: String) {
data object Warn : LogLevel("warn")
data object Trace : LogLevel("trace")
data object Info : LogLevel("info")
data object Debug : LogLevel("debug")
data object Error : LogLevel("error")
enum class LogLevel(open val filter: String) {
ERROR("error"),
WARN("warn"),
INFO("info"),
DEBUG("debug"),
TRACE("trace"),
}
object TracingFilterConfigurations {
val release = TracingFilterConfiguration(
overrides = mapOf(
Target.COMMON to LogLevel.Info,
Target.ELEMENT to LogLevel.Debug
Target.ELEMENT to LogLevel.DEBUG
),
)
val debug = TracingFilterConfiguration(
overrides = mapOf(
Target.COMMON to LogLevel.Info,
Target.ELEMENT to LogLevel.Trace
Target.ELEMENT to LogLevel.TRACE
)
)

View file

@ -35,6 +35,7 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.network)
implementation(projects.services.toolbox.api)
implementation(projects.libraries.featureflag.api)
api(projects.libraries.matrix.api)
implementation(libs.dagger)
implementation(projects.libraries.core)

View file

@ -67,6 +67,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.use
@ -100,9 +101,13 @@ class RustMatrixClient constructor(
client = client,
dispatchers = dispatchers,
)
private val notificationClient = client.notificationClient().use { builder ->
builder.filterByPushRules().finish()
}
private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(syncService)
private val notificationClient = client.notificationClient(notificationProcessSetup)
.use { builder ->
builder
.filterByPushRules()
.finish()
}
private val notificationSettings = client.getNotificationSettings()
private val notificationService = RustNotificationService(sessionId, notificationClient, dispatchers, clock)
@ -288,6 +293,7 @@ class RustMatrixClient constructor(
syncService.destroy()
innerRoomListService.destroy()
notificationClient.destroy()
notificationProcessSetup.destroy()
client.destroy()
}
@ -325,6 +331,7 @@ class RustMatrixClient constructor(
client.accountUrl()
}
}
override suspend fun loadUserDisplayName(): Result<String> = withContext(sessionDispatcher) {
runCatching {
client.displayName()

View file

@ -53,7 +53,8 @@ class RustMatrixClientFactory @Inject constructor(
client.restoreSession(sessionData.toSession())
val syncService = client.syncService().finish()
val syncService = client.syncService()
.finish()
RustMatrixClient(
client = client,

View file

@ -66,7 +66,7 @@ import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomSubscription
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import timber.log.Timber
import java.io.File
@ -227,31 +227,32 @@ class RustMatrixRoom(
}
}
override suspend fun sendMessage(message: String): Result<Unit> = withContext(roomDispatcher) {
override suspend fun sendMessage(body: String, htmlBody: String): Result<Unit> = withContext(roomDispatcher) {
val transactionId = genTransactionId()
messageEventContentFromMarkdown(message).use { content ->
messageEventContentFromHtml(body, htmlBody).use { content ->
runCatching {
innerRoom.send(content, transactionId)
}
}
}
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> = withContext(roomDispatcher) {
if (originalEventId != null) {
runCatching {
innerRoom.edit(messageEventContentFromMarkdown(message), originalEventId.value, transactionId?.value)
}
} else {
runCatching {
transactionId?.let { cancelSend(it) }
innerRoom.send(messageEventContentFromMarkdown(message), genTransactionId())
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> =
withContext(roomDispatcher) {
if (originalEventId != null) {
runCatching {
innerRoom.edit(messageEventContentFromHtml(body, htmlBody), originalEventId.value, transactionId?.value)
}
} else {
runCatching {
transactionId?.let { cancelSend(it) }
innerRoom.send(messageEventContentFromHtml(body, htmlBody), genTransactionId())
}
}
}
}
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(roomDispatcher) {
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.sendReply(messageEventContentFromMarkdown(message), eventId.value, genTransactionId())
innerRoom.sendReply(messageEventContentFromHtml(body, htmlBody), eventId.value, genTransactionId())
}
}

View file

@ -33,6 +33,8 @@ import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener
import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.RoomListServiceStateListener
import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicatorListener
import timber.log.Timber
fun RoomList.loadingStateFlow(): Flow<RoomListLoadingState> =
@ -83,6 +85,18 @@ fun RoomListService.stateFlow(): Flow<RoomListServiceState> =
}
}.buffer(Channel.UNLIMITED)
fun RoomListService.syncIndicator(): Flow<RoomListServiceSyncIndicator> =
mxCallbackFlow {
val listener = object : RoomListServiceSyncIndicatorListener {
override fun onUpdate(syncIndicator: RoomListServiceSyncIndicator) {
trySendBlocking(syncIndicator)
}
}
tryOrNull {
syncIndicator(listener)
}
}.buffer(Channel.UNLIMITED)
fun RoomListService.roomOrNull(roomId: String): RoomListItem? {
return try {
room(roomId)

View file

@ -20,25 +20,26 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.impl.room.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.use
class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFactory = RoomMessageFactory()) {
suspend fun create(roomListItem: RoomListItem, room: Room?): RoomSummaryDetails {
val latestRoomMessage = roomListItem.latestEvent()?.use {
fun create(roomInfo: RoomInfo): RoomSummaryDetails {
val latestRoomMessage = roomInfo.latestEvent?.use {
roomMessageFactory.create(it)
}
return RoomSummaryDetails(
roomId = RoomId(roomListItem.id()),
name = roomListItem.name() ?: roomListItem.id(),
canonicalAlias = roomListItem.canonicalAlias(),
isDirect = roomListItem.isDirect(),
avatarURLString = roomListItem.avatarUrl(),
unreadNotificationCount = roomListItem.unreadNotifications().use { it.notificationCount().toInt() },
roomId = RoomId(roomInfo.id),
name = roomInfo.name ?: roomInfo.id,
canonicalAlias = roomInfo.canonicalAlias,
isDirect = roomInfo.isDirect,
avatarURLString = roomInfo.avatarUrl,
unreadNotificationCount = roomInfo.notificationCount.toInt(),
lastMessage = latestRoomMessage,
lastMessageTimestamp = latestRoomMessage?.originServerTs,
inviter = room?.inviter()?.let(RoomMemberMapper::map),
inviter = roomInfo.inviter?.let(RoomMemberMapper::map),
)
}
}

View file

@ -22,11 +22,10 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListEntry
import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.util.UUID
@ -34,7 +33,6 @@ class RoomSummaryListProcessor(
private val roomSummaries: MutableStateFlow<List<RoomSummary>>,
private val roomListService: RoomListService,
private val roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
private val shouldFetchFullRoom: Boolean = false,
) {
private val roomSummariesByIdentifier = HashMap<String, RoomSummary>()
@ -120,9 +118,9 @@ class RoomSummaryListProcessor(
private suspend fun buildAndCacheRoomSummaryForIdentifier(identifier: String): RoomSummary {
val builtRoomSummary = roomListService.roomOrNull(identifier)?.use { roomListItem ->
roomListItem.fullRoomOrNull().use { fullRoom ->
roomListItem.roomInfo().use { roomInfo ->
RoomSummary.Filled(
details = roomSummaryDetailsFactory.create(roomListItem, fullRoom)
details = roomSummaryDetailsFactory.create(roomInfo)
)
}
} ?: buildEmptyRoomSummary()
@ -130,14 +128,6 @@ class RoomSummaryListProcessor(
return builtRoomSummary
}
private fun RoomListItem.fullRoomOrNull(): Room? {
return if (shouldFetchFullRoom) {
fullRoom()
} else {
null
}
}
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) =
mutex.withLock {
val mutableRoomSummaries = roomSummaries.value.toMutableList()

View file

@ -38,6 +38,7 @@ import org.matrix.rustcomponents.sdk.RoomListInput
import org.matrix.rustcomponents.sdk.RoomListLoadingState
import org.matrix.rustcomponents.sdk.RoomListRange
import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator
import timber.log.Timber
import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService
@ -52,9 +53,9 @@ class RustRoomListService(
private val inviteRooms = MutableStateFlow<List<RoomSummary>>(emptyList())
private val allRoomsLoadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, innerRoomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = false)
private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, innerRoomListService, roomSummaryDetailsFactory)
private val invitesLoadingState: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, innerRoomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = true)
private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, innerRoomListService, roomSummaryDetailsFactory)
init {
sessionCoroutineScope.launch(dispatcher) {
@ -106,6 +107,15 @@ class RustRoomListService(
}
}
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> =
innerRoomListService.syncIndicator()
.map { it.toSyncIndicator() }
.onEach { syncIndicator ->
Timber.d("SyncIndicator = $syncIndicator")
}
.distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.SyncIndicator.Hide)
override val state: StateFlow<RoomListService.State> =
innerRoomListService.stateFlow()
.map { it.toRoomListState() }
@ -126,6 +136,7 @@ private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState {
private fun RoomListServiceState.toRoomListState(): RoomListService.State {
return when (this) {
RoomListServiceState.INITIAL,
RoomListServiceState.RECOVERING,
RoomListServiceState.SETTING_UP -> RoomListService.State.Idle
RoomListServiceState.RUNNING -> RoomListService.State.Running
RoomListServiceState.ERROR -> RoomListService.State.Error
@ -133,6 +144,13 @@ private fun RoomListServiceState.toRoomListState(): RoomListService.State {
}
}
private fun RoomListServiceSyncIndicator.toSyncIndicator(): RoomListService.SyncIndicator {
return when (this) {
RoomListServiceSyncIndicator.SHOW -> RoomListService.SyncIndicator.Show
RoomListServiceSyncIndicator.HIDE -> RoomListService.SyncIndicator.Hide
}
}
private fun org.matrix.rustcomponents.sdk.RoomList.observeEntriesWithProcessor(processor: RoomSummaryListProcessor): Flow<List<RoomListEntriesUpdate>> {
return entriesFlow { roomListEntries ->
processor.postEntries(roomListEntries)

View file

@ -34,85 +34,92 @@ import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.map
import org.matrix.rustcomponents.sdk.TimelineItemContent
import org.matrix.rustcomponents.sdk.TimelineItemContentKind
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.EncryptedMessage as RustEncryptedMessage
import org.matrix.rustcomponents.sdk.MembershipChange as RustMembershipChange
import org.matrix.rustcomponents.sdk.OtherState as RustOtherState
class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMapper = EventMessageMapper()) {
fun map(content: TimelineItemContent): EventContent = content.use {
when (val kind = it.kind()) {
is TimelineItemContentKind.FailedToParseMessageLike -> {
FailedToParseMessageLikeContent(
eventType = kind.eventType,
error = kind.error
)
fun map(content: TimelineItemContent): EventContent {
return content.use {
content.kind().use { kind ->
map(content, kind)
}
is TimelineItemContentKind.FailedToParseState -> {
FailedToParseStateContent(
eventType = kind.eventType,
stateKey = kind.stateKey,
error = kind.error
)
}
TimelineItemContentKind.Message -> {
val message = it.asMessage()
if (message == null) {
UnknownContent
} else {
eventMessageMapper.map(message)
}
}
is TimelineItemContentKind.ProfileChange -> {
ProfileChangeContent(
displayName = kind.displayName,
prevDisplayName = kind.prevDisplayName,
avatarUrl = kind.avatarUrl,
prevAvatarUrl = kind.prevAvatarUrl
)
}
TimelineItemContentKind.RedactedMessage -> {
RedactedContent
}
is TimelineItemContentKind.RoomMembership -> {
RoomMembershipContent(
UserId(kind.userId),
kind.change?.map()
)
}
is TimelineItemContentKind.State -> {
StateContent(
stateKey = kind.stateKey,
content = kind.content.map()
)
}
is TimelineItemContentKind.Sticker -> {
StickerContent(
body = kind.body,
info = kind.info.map(),
url = kind.url,
)
}
is TimelineItemContentKind.Poll -> {
PollContent(
question = kind.question,
kind = kind.kind.map(),
maxSelections = kind.maxSelections,
answers = kind.answers.map { answer -> answer.map() },
votes = kind.votes.mapValues { vote ->
vote.value.map { userId -> UserId(userId) }
},
endTime = kind.endTime,
)
}
is TimelineItemContentKind.UnableToDecrypt -> {
UnableToDecryptContent(
data = kind.msg.map()
)
}
else -> UnknownContent
}
}
private fun map(content: TimelineItemContent, kind: TimelineItemContentKind) = when (kind) {
is TimelineItemContentKind.FailedToParseMessageLike -> {
FailedToParseMessageLikeContent(
eventType = kind.eventType,
error = kind.error
)
}
is TimelineItemContentKind.FailedToParseState -> {
FailedToParseStateContent(
eventType = kind.eventType,
stateKey = kind.stateKey,
error = kind.error
)
}
TimelineItemContentKind.Message -> {
val message = content.asMessage()
if (message == null) {
UnknownContent
} else {
eventMessageMapper.map(message)
}
}
is TimelineItemContentKind.ProfileChange -> {
ProfileChangeContent(
displayName = kind.displayName,
prevDisplayName = kind.prevDisplayName,
avatarUrl = kind.avatarUrl,
prevAvatarUrl = kind.prevAvatarUrl
)
}
TimelineItemContentKind.RedactedMessage -> {
RedactedContent
}
is TimelineItemContentKind.RoomMembership -> {
RoomMembershipContent(
UserId(kind.userId),
kind.change?.map()
)
}
is TimelineItemContentKind.State -> {
StateContent(
stateKey = kind.stateKey,
content = kind.content.map()
)
}
is TimelineItemContentKind.Sticker -> {
StickerContent(
body = kind.body,
info = kind.info.map(),
url = kind.url,
)
}
is TimelineItemContentKind.Poll -> {
PollContent(
question = kind.question,
kind = kind.kind.map(),
maxSelections = kind.maxSelections,
answers = kind.answers.map { answer -> answer.map() },
votes = kind.votes.mapValues { vote ->
vote.value.map { userId -> UserId(userId) }
},
endTime = kind.endTime,
)
}
is TimelineItemContentKind.UnableToDecrypt -> {
UnableToDecryptContent(
data = kind.msg.map()
)
}
else -> UnknownContent
}
}
private fun RustMembershipChange.map(): MembershipChange {

View file

@ -21,47 +21,45 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_SETTINGS
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
class FakeNotificationSettingsService : NotificationSettingsService {
class FakeNotificationSettingsService(
initialMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE,
initialDefaultMode: RoomNotificationMode = A_ROOM_NOTIFICATION_MODE
) : NotificationSettingsService {
private var _roomNotificationSettingsStateFlow = MutableStateFlow(Unit)
private val muteRoomResult: Result<Unit> = Result.success(Unit)
private val unmuteRoomResult: Result<Unit> = Result.success(Unit)
private val setRoomNotificationMode: Result<Unit> = Result.success(Unit)
private val restoreDefaultRoomNotificationMode: Result<Unit> = Result.success(Unit)
private val getRoomNotificationSettingsResult: Result<RoomNotificationSettings> = Result.success(A_ROOM_NOTIFICATION_SETTINGS)
private val getDefaultRoomNotificationMode: Result<RoomNotificationMode> = Result.success(A_ROOM_NOTIFICATION_MODE)
private var defaultRoomNotificationMode: RoomNotificationMode = initialDefaultMode
private var roomNotificationMode: RoomNotificationMode = initialMode
override val notificationSettingsChangeFlow: SharedFlow<Unit>
get() = _roomNotificationSettingsStateFlow
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings> {
return getRoomNotificationSettingsResult
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationSettings> {
return Result.success(RoomNotificationSettings(mode = roomNotificationMode, isDefault = roomNotificationMode == defaultRoomNotificationMode))
}
override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationMode> {
return getDefaultRoomNotificationMode
}
override suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isOneToOne: Boolean): Result<Unit> {
TODO("Not yet implemented")
override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, membersCount: Long): Result<RoomNotificationMode> {
return Result.success(defaultRoomNotificationMode)
}
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> {
return setRoomNotificationMode
roomNotificationMode = mode
_roomNotificationSettingsStateFlow.emit(Unit)
return Result.success(Unit)
}
override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> {
return restoreDefaultRoomNotificationMode
roomNotificationMode = defaultRoomNotificationMode
_roomNotificationSettingsStateFlow.emit(Unit)
return Result.success(Unit)
}
override suspend fun muteRoom(roomId: RoomId): Result<Unit> {
return muteRoomResult
return setRoomNotificationMode(roomId, RoomNotificationMode.MUTE)
}
override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<Unit> {
return unmuteRoomResult
override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, membersCount: Long): Result<Unit> {
return restoreDefaultRoomNotificationMode(roomId)
}
override suspend fun isRoomMentionEnabled(): Result<Boolean> {
@ -79,5 +77,4 @@ class FakeNotificationSettingsService : NotificationSettingsService {
override suspend fun setCallEnabled(enabled: Boolean): Result<Unit> {
return Result.success(Unit)
}
}

View file

@ -27,6 +27,7 @@ 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.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
@ -38,6 +39,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.delay
@ -59,6 +61,7 @@ class FakeMatrixRoom(
override val isDirect: Boolean = false,
override val joinedMemberCount: Long = 123L,
override val activeMemberCount: Long = 234L,
val notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
canRedact: Boolean = false,
) : MatrixRoom {
@ -90,7 +93,7 @@ class FakeMatrixRoom(
private var sendPollResponseResult = Result.success(Unit)
private var endPollResult = Result.success(Unit)
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
val editMessageCalls = mutableListOf<String>()
val editMessageCalls = mutableListOf<Pair<String, String>>()
var sendMediaCount = 0
private set
@ -146,7 +149,9 @@ class FakeMatrixRoom(
}
override suspend fun updateRoomNotificationSettings(): Result<Unit> = simulateLongTask {
updateRoomNotificationSettingsResult
val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, activeMemberCount).getOrThrow()
roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(notificationSettings)
return Result.success(Unit)
}
override val syncUpdateFlow: StateFlow<Long> = MutableStateFlow(0L)
@ -167,7 +172,7 @@ class FakeMatrixRoom(
userAvatarUrlResult
}
override suspend fun sendMessage(message: String): Result<Unit> = simulateLongTask {
override suspend fun sendMessage(body: String, htmlBody: String) = simulateLongTask {
Result.success(Unit)
}
@ -196,16 +201,16 @@ class FakeMatrixRoom(
return cancelSendResult
}
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> {
editMessageCalls += message
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, body: String, htmlBody: String): Result<Unit> {
editMessageCalls += body to htmlBody
return Result.success(Unit)
}
var replyMessageParameter: String? = null
var replyMessageParameter: Pair<String, String>? = null
private set
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> {
replyMessageParameter = message
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String): Result<Unit> {
replyMessageParameter = body to htmlBody
return Result.success(Unit)
}

View file

@ -29,6 +29,7 @@ class FakeRoomListService : RoomListService {
private val allRoomsLoadingStateFlow = MutableStateFlow<RoomList.LoadingState>(RoomList.LoadingState.NotLoaded)
private val inviteRoomsLoadingStateFlow = MutableStateFlow<RoomList.LoadingState>(RoomList.LoadingState.NotLoaded)
private val roomListStateFlow = MutableStateFlow<RoomListService.State>(RoomListService.State.Idle)
private val syncIndicatorStateFlow = MutableStateFlow<RoomListService.SyncIndicator>(RoomListService.SyncIndicator.Hide)
suspend fun postAllRooms(roomSummaries: List<RoomSummary>) {
allRoomSummariesFlow.emit(roomSummaries)
@ -72,4 +73,6 @@ class FakeRoomListService : RoomListService {
}
override val state: StateFlow<RoomListService.State> = roomListStateFlow
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> = syncIndicatorStateFlow
}

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.ui.media
import android.content.Context
import coil.ImageLoader
import coil.decode.DataSource
import coil.decode.ImageSource
@ -32,8 +33,10 @@ import okio.Buffer
import okio.Path.Companion.toOkioPath
import timber.log.Timber
import java.nio.ByteBuffer
import kotlin.math.roundToLong
internal class CoilMediaFetcher(
private val scalingFunction: (Float) -> Float,
private val mediaLoader: MatrixMediaLoader,
private val mediaData: MediaRequestData?,
private val options: Options
@ -80,8 +83,8 @@ internal class CoilMediaFetcher(
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail, options: Options): FetchResult? {
return mediaLoader.loadMediaThumbnail(
source = mediaSource,
width = kind.width,
height = kind.height
width = scalingFunction(kind.width.toFloat()).roundToLong(),
height = scalingFunction(kind.height.toFloat()).roundToLong(),
).map { byteArray ->
byteArray.asSourceResult(options)
}.getOrNull()
@ -102,6 +105,7 @@ internal class CoilMediaFetcher(
}
class MediaRequestDataFactory(
private val context: Context,
private val client: MatrixClient
) :
Fetcher.Factory<MediaRequestData> {
@ -111,6 +115,7 @@ internal class CoilMediaFetcher(
imageLoader: ImageLoader
): Fetcher {
return CoilMediaFetcher(
scalingFunction = { context.resources.displayMetrics.density * it },
mediaLoader = client.mediaLoader,
mediaData = data,
options = options
@ -119,6 +124,7 @@ internal class CoilMediaFetcher(
}
class AvatarFactory(
private val context: Context,
private val client: MatrixClient
) :
Fetcher.Factory<AvatarData> {
@ -129,6 +135,7 @@ internal class CoilMediaFetcher(
imageLoader: ImageLoader
): Fetcher {
return CoilMediaFetcher(
scalingFunction = { context.resources.displayMetrics.density * it },
mediaLoader = client.mediaLoader,
mediaData = data.toMediaRequestData(),
options = options

View file

@ -46,8 +46,8 @@ class LoggedInImageLoaderFactory @Inject constructor(
}
add(AvatarDataKeyer())
add(MediaRequestDataKeyer())
add(CoilMediaFetcher.AvatarFactory(matrixClient))
add(CoilMediaFetcher.MediaRequestDataFactory(matrixClient))
add(CoilMediaFetcher.AvatarFactory(context, matrixClient))
add(CoilMediaFetcher.MediaRequestDataFactory(context, matrixClient))
}
.build()
}

View file

@ -119,10 +119,17 @@ class AndroidMediaPreProcessor @Inject constructor(
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
suspend fun processImageWithCompression(): MediaUploadInfo {
// Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail.
val orientation = contentResolver.openInputStream(uri).use { input ->
val exifInterface = input?.let { ExifInterface(it) }
exifInterface?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
} ?: ExifInterface.ORIENTATION_UNDEFINED
val compressionResult = contentResolver.openInputStream(uri).use { input ->
imageCompressor.compressToTmpFile(
inputStream = requireNotNull(input),
resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE),
orientation = orientation,
).getOrThrow()
}
val thumbnailResult: ThumbnailResult = thumbnailFactory.createImageThumbnail(compressionResult.file)

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.mediaupload
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.exifinterface.media.ExifInterface
import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize
import io.element.android.libraries.androidutils.bitmap.resizeToMax
import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation
@ -37,17 +38,18 @@ class ImageCompressor @Inject constructor(
/**
* Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode], then writes it into a
* temporary file using the passed [format] and [desiredQuality].
* temporary file using the passed [format], [orientation] and [desiredQuality].
* @return a [Result] containing the resulting [ImageCompressionResult] with the temporary [File] and some metadata.
*/
suspend fun compressToTmpFile(
inputStream: InputStream,
resizeMode: ResizeMode,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
orientation: Int = ExifInterface.ORIENTATION_UNDEFINED,
desiredQuality: Int = 80,
): Result<ImageCompressionResult> = withContext(Dispatchers.IO) {
runCatching {
val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow()
val compressedBitmap = compressToBitmap(inputStream, resizeMode, orientation).getOrThrow()
// Encode bitmap to the destination temporary file
val tmpFile = context.createTmpFile(extension = "jpeg")
tmpFile.outputStream().use {
@ -63,19 +65,20 @@ class ImageCompressor @Inject constructor(
}
/**
* Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode].
* Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode] and [orientation].
* @return a [Result] containing the resulting [Bitmap].
*/
fun compressToBitmap(
inputStream: InputStream,
resizeMode: ResizeMode,
orientation: Int,
): Result<Bitmap> = runCatching {
BufferedInputStream(inputStream).use { input ->
val options = BitmapFactory.Options()
calculateDecodingScale(input, resizeMode, options)
val decodedBitmap = BitmapFactory.decodeStream(input, null, options)
?: error("Decoding Bitmap from InputStream failed")
val rotatedBitmap = decodedBitmap.rotateToMetadataOrientation(input).getOrThrow()
val rotatedBitmap = decodedBitmap.rotateToMetadataOrientation(orientation)
if (resizeMode is ResizeMode.Strict) {
rotatedBitmap.resizeToMax(resizeMode.maxWidth, resizeMode.maxHeight)
} else {

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.permissions.api
import kotlinx.coroutines.flow.Flow
interface PermissionStateProvider {
fun isPermissionGranted(permission: String): Boolean
suspend fun setPermissionDenied(permission: String, value: Boolean)
fun isPermissionDenied(permission: String): Flow<Boolean>
suspend fun setPermissionAsked(permission: String, value: Boolean)
fun isPermissionAsked(permission: String): Flow<Boolean>
suspend fun resetPermission(permission: String)
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.permissions.impl
package io.element.android.libraries.permissions.api
import kotlinx.coroutines.flow.Flow

View file

@ -56,6 +56,8 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

View file

@ -26,13 +26,13 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface PermissionStateProvider {
interface ComposablePermissionStateProvider {
@Composable
fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState
}
@ContributesBinding(AppScope::class)
class AccompanistPermissionStateProvider @Inject constructor() : PermissionStateProvider {
class AccompanistPermissionStateProvider @Inject constructor() : ComposablePermissionStateProvider {
@Composable
override fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState {
return rememberPermissionState(

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.permissions.impl
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsStore
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultPermissionStateProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val permissionsStore: PermissionsStore,
): PermissionStateProvider {
override fun isPermissionGranted(permission: String): Boolean {
return context.checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override suspend fun setPermissionDenied(permission: String, value: Boolean) = permissionsStore.setPermissionDenied(permission, value)
override fun isPermissionDenied(permission: String): Flow<Boolean> = permissionsStore.isPermissionDenied(permission)
override suspend fun setPermissionAsked(permission: String, value: Boolean) = permissionsStore.setPermissionAsked(permission, value)
override fun isPermissionAsked(permission: String): Flow<Boolean> = permissionsStore.isPermissionAsked(permission)
override suspend fun resetPermission(permission: String) = permissionsStore.resetPermission(permission)
}

View file

@ -38,6 +38,7 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.PermissionsStore
import kotlinx.coroutines.launch
import timber.log.Timber
@ -46,7 +47,7 @@ private val loggerTag = LoggerTag("DefaultPermissionsPresenter")
class DefaultPermissionsPresenter @AssistedInject constructor(
@Assisted val permission: String,
private val permissionsStore: PermissionsStore,
private val permissionStateProvider: PermissionStateProvider,
private val composablePermissionStateProvider: ComposablePermissionStateProvider,
) : PermissionsPresenter {
@AssistedFactory
@ -90,7 +91,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
}
}
permissionState = permissionStateProvider.provide(
permissionState = composablePermissionStateProvider.provide(
permission = permission,
onPermissionResult = ::onPermissionResult
)
@ -123,10 +124,8 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
showDialog = showDialog.value,
permissionAlreadyAsked = isAlreadyAsked,
permissionAlreadyDenied = isAlreadyDenied,
eventSink = ::handleEvents
).also {
Timber.tag(loggerTag.value).d("New state: $it")
}
eventSink = { handleEvents(it) }
)
}
/*

View file

@ -26,6 +26,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.permissions.api.PermissionsStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@ -34,7 +35,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
@ContributesBinding(AppScope::class)
class DefaultPermissionsStore @Inject constructor(
@ApplicationContext context: Context,
@ApplicationContext private val context: Context,
) : PermissionsStore {
private val store = context.dataStore

View file

@ -0,0 +1,53 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.permissions.impl
import io.element.android.libraries.permissions.api.PermissionStateProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakePermissionStateProvider(
private var permissionGranted: Boolean = true,
permissionDenied: Boolean = false,
permissionAsked: Boolean = false,
): PermissionStateProvider {
private val permissionDeniedFlow = MutableStateFlow(permissionDenied)
private val permissionAskedFlow = MutableStateFlow(permissionAsked)
fun setPermissionGranted() {
permissionGranted = true
}
override fun isPermissionGranted(permission: String): Boolean = permissionGranted
override suspend fun setPermissionDenied(permission: String, value: Boolean) {
permissionDeniedFlow.value = value
}
override fun isPermissionDenied(permission: String): Flow<Boolean> = permissionDeniedFlow
override suspend fun setPermissionAsked(permission: String, value: Boolean) {
permissionAskedFlow.value = value
}
override fun isPermissionAsked(permission: String): Flow<Boolean> = permissionAskedFlow
override suspend fun resetPermission(permission: String) {
setPermissionAsked(permission, false)
setPermissionDenied(permission, false)
}
}

View file

@ -25,17 +25,31 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.test.InMemoryPermissionsStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
const val A_PERMISSION = "A_PERMISSION"
class DefaultPermissionsPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Granted)
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Granted
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -57,8 +71,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user closes dialog`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -77,8 +97,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user does not grant permission`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -106,8 +132,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user does not grant permission second time`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = true))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = true)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -134,9 +166,19 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user does not grant permission third time`() = runTest {
val permissionsStore = InMemoryPermissionsStore(permissionDenied = true, permissionAsked = true)
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionsStore =
InMemoryPermissionsStore(
permissionDenied = true,
permissionAsked = true
)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -157,8 +199,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user grants permission`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,

View file

@ -27,9 +27,9 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
class FakePermissionStateProvider constructor(
class FakeComposablePermissionStateProvider constructor(
private val permissionState: FakePermissionState
) : PermissionStateProvider {
) : ComposablePermissionStateProvider {
private lateinit var onPermissionResult: (Boolean) -> Unit
@OptIn(ExperimentalPermissionsApi::class)

View file

@ -31,4 +31,5 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.tests.testutils)
}

View file

@ -20,10 +20,17 @@ 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.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class NoopPermissionsPresenterTest {
@Rule
@JvmField
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = NoopPermissionsPresenter()

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.permissions.test"
}
dependencies {
implementation(projects.libraries.architecture)
api(projects.libraries.permissions.api)
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.permissions.test
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState
class FakePermissionsPresenter(
private val initialState: PermissionsState = aPermissionsState().copy(showDialog = false),
) : PermissionsPresenter {
private fun eventSink(events: PermissionsEvents) {
when (events) {
PermissionsEvents.OpenSystemDialog -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true)
PermissionsEvents.CloseDialog -> state.value = state.value.copy(showDialog = false)
}
}
private val state = mutableStateOf(initialState.copy(eventSink = ::eventSink))
fun setPermissionGranted() {
state.value = state.value.copy(permissionGranted = true)
}
fun setPermissionDenied() {
state.value = state.value.copy(permissionAlreadyDenied = true)
}
@Composable
override fun present(): PermissionsState {
return state.value
}
}

View file

@ -14,8 +14,9 @@
* limitations under the License.
*/
package io.element.android.libraries.permissions.impl
package io.element.android.libraries.permissions.test
import io.element.android.libraries.permissions.api.PermissionsStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -43,6 +44,5 @@ class InMemoryPermissionsStore(
setPermissionDenied(permission, false)
}
override suspend fun resetStore() {
}
override suspend fun resetStore() = Unit
}

View file

@ -131,6 +131,6 @@ class PendingIntentFactory @Inject constructor(
fun createInviteListPendingIntent(sessionId: SessionId): PendingIntent {
val intent = intentProvider.getInviteListIntent(sessionId)
return PendingIntentCompat.getActivity(context, 0, intent, 0, false)
return PendingIntentCompat.getActivity(context, 0, intent, 0, false)!!
}
}

View file

@ -20,6 +20,13 @@ plugins {
android {
namespace = "io.element.android.libraries.pushproviders.firebase"
buildTypes {
release {
isMinifyEnabled = true
consumerProguardFiles("consumer-proguard-rules.pro")
}
}
}
anvil {
@ -38,7 +45,11 @@ dependencies {
implementation(projects.libraries.pushproviders.api)
api(platform(libs.google.firebase.bom))
api("com.google.firebase:firebase-messaging-ktx")
api("com.google.firebase:firebase-messaging-ktx") {
exclude(group = "com.google.firebase", module = "firebase-core")
exclude(group = "com.google.firebase", module = "firebase-analytics")
exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
}
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)

View file

@ -0,0 +1,4 @@
# Fix this error:
# ERROR: Missing classes detected while running R8. Please add the missing classes or apply additional keep rules that are generated in /Users/bmarty/workspaces/element-x-android/app/build/outputs/mapping/nightly/missing_rules.txt.
# ERROR: R8: Missing class com.google.firebase.analytics.connector.AnalyticsConnector (referenced from: void com.google.firebase.messaging.MessagingAnalytics.logToScion(java.lang.String, android.os.Bundle) and 1 other context)
-dontwarn com.google.firebase.analytics.connector.AnalyticsConnector

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -31,5 +31,11 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.matrix.richtexteditor)
api(libs.matrix.richtexteditor.compose)
ksp(libs.showkase.processor)
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer
data class Message(
val html: String,
val markdown: String,
)

View file

@ -0,0 +1,757 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension.Companion.fillToConstraints
import androidx.constraintlayout.compose.Visibility
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.applyScaleUp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.textcomposer.components.FormattingOption
import io.element.android.libraries.textcomposer.components.FormattingOptionState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.RichTextEditor
import io.element.android.wysiwyg.compose.RichTextEditorDefaults
import io.element.android.wysiwyg.compose.RichTextEditorState
import io.element.android.wysiwyg.view.models.InlineFormat
import kotlinx.coroutines.android.awaitFrame
import uniffi.wysiwyg_composer.ActionState
import uniffi.wysiwyg_composer.ComposerAction
@Composable
fun TextComposer(
state: RichTextEditorState,
composerMode: MessageComposerMode,
canSendMessage: Boolean,
modifier: Modifier = Modifier,
showTextFormatting: Boolean = false,
onRequestFocus: () -> Unit = {},
onSendMessage: (Message) -> Unit = {},
onResetComposerMode: () -> Unit = {},
onAddAttachment: () -> Unit = {},
onDismissTextFormatting: () -> Unit = {},
onError: (Throwable) -> Unit = {},
) {
val onSendClicked = {
onSendMessage(Message(html = state.messageHtml, markdown = state.messageMarkdown))
}
Column(
modifier = modifier
.padding(
start = 3.dp,
end = 6.dp,
top = 8.dp,
bottom = 5.dp,
)
.fillMaxWidth(),
) {
ConstraintLayout(
modifier = Modifier.fillMaxWidth(),
) {
val (composeOptions, textInput, sendButton) = createRefs()
val showComposerOptionsButton by remember(showTextFormatting) {
derivedStateOf { !showTextFormatting }
}
IconButton(
modifier = Modifier
.size(48.dp)
.constrainAs(composeOptions) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
visibility = if (showComposerOptionsButton) Visibility.Visible else Visibility.Gone
},
onClick = onAddAttachment
) {
Icon(
modifier = Modifier.size(30.dp.applyScaleUp()),
resourceId = R.drawable.ic_plus, // TODO Replace with design system icon when available
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
tint = ElementTheme.colors.iconPrimary,
)
}
val roundCornerSmall = 20.dp.applyScaleUp()
val roundCornerLarge = 28.dp.applyScaleUp()
val roundedCornerSize = remember(state.lineCount, composerMode) {
if (state.lineCount > 1 || composerMode is MessageComposerMode.Special) {
roundCornerSmall
} else {
roundCornerLarge
}
}
val roundedCornerSizeState = animateDpAsState(
targetValue = roundedCornerSize,
animationSpec = tween(
durationMillis = 100,
)
)
val roundedCorners = RoundedCornerShape(roundedCornerSizeState.value)
val colors = ElementTheme.colors
val bgColor = colors.bgSubtleSecondary
val borderColor by remember(state.hasFocus, colors) {
derivedStateOf {
if (state.hasFocus) colors.borderDisabled else bgColor
}
}
Column(
modifier = Modifier
.constrainAs(textInput) {
start.linkTo(composeOptions.end, margin = 3.dp, goneMargin = 9.dp)
end.linkTo(sendButton.start, margin = 6.dp, goneMargin = 6.dp)
bottom.linkTo(parent.bottom)
width = fillToConstraints
}
.padding(vertical = 3.dp)
.fillMaxWidth()
.clip(roundedCorners)
.background(color = bgColor)
.border(1.dp, borderColor, roundedCorners)
) {
if (composerMode is MessageComposerMode.Special) {
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode)
}
TextInput(
state = state,
roundedCorners = roundedCorners,
bgColor = bgColor,
onError = onError,
)
}
SendButton(
canSendMessage = canSendMessage,
onClick = onSendClicked,
composerMode = composerMode,
modifier = Modifier
.constrainAs(sendButton) {
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
visibility = if (!showTextFormatting) Visibility.Visible else Visibility.Gone
}
)
}
if (showTextFormatting) {
TextFormatting(
state = state,
onDismiss = onDismissTextFormatting,
sendButton = {
SendButton(
canSendMessage = canSendMessage,
onClick = onSendClicked,
composerMode = composerMode,
modifier = it
)
},
)
}
}
// Request focus when changing mode, and show keyboard.
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(composerMode) {
if (composerMode is MessageComposerMode.Special) {
onRequestFocus()
keyboard?.let {
awaitFrame()
it.show()
}
}
}
}
@Composable
private fun TextInput(
state: RichTextEditorState,
roundedCorners: RoundedCornerShape,
bgColor: Color,
modifier: Modifier = Modifier,
onError: (Throwable) -> Unit = {},
) {
val minHeight = 42.dp.applyScaleUp()
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
Box(
modifier = modifier
.heightIn(min = minHeight)
.background(color = bgColor, shape = roundedCorners)
.padding(
PaddingValues(
top = 4.dp.applyScaleUp(),
bottom = 4.dp.applyScaleUp(),
start = 12.dp.applyScaleUp(),
end = 42.dp.applyScaleUp()
)
),
contentAlignment = Alignment.CenterStart,
) {
// Placeholder
if (state.messageHtml.isEmpty()) {
Text(
stringResource(CommonStrings.common_message),
style = defaultTypography.copy(
color = ElementTheme.colors.textDisabled,
),
)
}
RichTextEditor(
state = state,
modifier = Modifier
.fillMaxWidth(),
style = RichTextEditorDefaults.style(
text = RichTextEditorDefaults.textStyle(
color = if (state.hasFocus) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.secondary
}
),
cursor = RichTextEditorDefaults.cursorStyle(
color = ElementTheme.colors.iconAccentTertiary,
)
),
onError = onError
)
}
}
@Composable
private fun TextFormatting(
state: RichTextEditorState,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
sendButton: @Composable (modifier: Modifier) -> Unit,
) {
ConstraintLayout(
modifier = modifier
.fillMaxWidth()
) {
val (close, formatting, send) = createRefs()
IconButton(
modifier = Modifier
.size(48.dp)
.constrainAs(close) {
start.linkTo(parent.start)
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
},
onClick = onDismiss
) {
Icon(
modifier = Modifier.size(30.dp.applyScaleUp()),
resourceId = R.drawable.ic_cancel, // TODO Replace with design system icon when available
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.colors.iconPrimary,
)
}
val scrollState = rememberScrollState()
Row(
modifier = Modifier
.constrainAs(formatting) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(close.end, margin = 3.dp)
end.linkTo(send.start, margin = 20.dp)
width = fillToConstraints
}
.horizontalScroll(scrollState),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
FormattingOption(
state = state.actions[ComposerAction.BOLD].toButtonState(),
onClick = { state.toggleInlineFormat(InlineFormat.Bold) },
imageVector = ImageVector.vectorResource(VectorIcons.Bold),
contentDescription = stringResource(CommonStrings.rich_text_editor_format_bold)
)
FormattingOption(
state = state.actions[ComposerAction.ITALIC].toButtonState(),
onClick = { state.toggleInlineFormat(InlineFormat.Italic) },
imageVector = ImageVector.vectorResource(VectorIcons.Italic),
contentDescription = stringResource(CommonStrings.rich_text_editor_format_italic)
)
FormattingOption(
state = state.actions[ComposerAction.UNDERLINE].toButtonState(),
onClick = { state.toggleInlineFormat(InlineFormat.Underline) },
imageVector = ImageVector.vectorResource(VectorIcons.Underline),
contentDescription = stringResource(CommonStrings.rich_text_editor_format_underline)
)
FormattingOption(
state = state.actions[ComposerAction.STRIKE_THROUGH].toButtonState(),
onClick = { state.toggleInlineFormat(InlineFormat.StrikeThrough) },
imageVector = ImageVector.vectorResource(VectorIcons.Strikethrough),
contentDescription = stringResource(CommonStrings.rich_text_editor_format_strikethrough)
)
FormattingOption(
state = state.actions[ComposerAction.UNORDERED_LIST].toButtonState(),
onClick = { state.toggleList(ordered = false) },
imageVector = ImageVector.vectorResource(VectorIcons.BulletList),
contentDescription = stringResource(CommonStrings.rich_text_editor_bullet_list)
)
FormattingOption(
state = state.actions[ComposerAction.ORDERED_LIST].toButtonState(),
onClick = { state.toggleList(ordered = true) },
imageVector = ImageVector.vectorResource(VectorIcons.NumberedList),
contentDescription = stringResource(CommonStrings.rich_text_editor_numbered_list)
)
FormattingOption(
state = state.actions[ComposerAction.INDENT].toButtonState(),
onClick = { state.indent() },
imageVector = ImageVector.vectorResource(VectorIcons.IndentIncrease),
contentDescription = stringResource(CommonStrings.rich_text_editor_indent)
)
FormattingOption(
state = state.actions[ComposerAction.UNINDENT].toButtonState(),
onClick = { state.unindent() },
imageVector = ImageVector.vectorResource(VectorIcons.IndentDecrease),
contentDescription = stringResource(CommonStrings.rich_text_editor_unindent)
)
FormattingOption(
state = state.actions[ComposerAction.INLINE_CODE].toButtonState(),
onClick = { state.toggleInlineFormat(InlineFormat.InlineCode) },
imageVector = ImageVector.vectorResource(VectorIcons.InlineCode),
contentDescription = stringResource(CommonStrings.rich_text_editor_inline_code)
)
FormattingOption(
state = state.actions[ComposerAction.CODE_BLOCK].toButtonState(),
onClick = { state.toggleCodeBlock() },
imageVector = ImageVector.vectorResource(VectorIcons.CodeBlock),
contentDescription = stringResource(CommonStrings.rich_text_editor_code_block)
)
FormattingOption(
state = state.actions[ComposerAction.QUOTE].toButtonState(),
onClick = { state.toggleQuote() },
imageVector = ImageVector.vectorResource(VectorIcons.Quote),
contentDescription = stringResource(CommonStrings.rich_text_editor_quote)
)
}
sendButton(
Modifier.constrainAs(send) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
},
)
}
}
private fun ActionState?.toButtonState(): FormattingOptionState =
when (this) {
ActionState.ENABLED -> FormattingOptionState.Default
ActionState.REVERSED -> FormattingOptionState.Selected
ActionState.DISABLED, null -> FormattingOptionState.Disabled
}
@Composable
private fun ComposerModeView(
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
when (composerMode) {
is MessageComposerMode.Edit -> {
EditingModeView(onResetComposerMode = onResetComposerMode, modifier = modifier)
}
is MessageComposerMode.Reply -> {
ReplyToModeView(
modifier = modifier.padding(8.dp),
senderName = composerMode.senderName,
text = composerMode.defaultContent,
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
onResetComposerMode = onResetComposerMode,
)
}
else -> Unit
}
}
@Composable
private fun EditingModeView(
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(start = 12.dp)
) {
Icon(
resourceId = VectorIcons.Edit,
contentDescription = stringResource(CommonStrings.common_editing),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.size(16.dp.applyScaleUp()),
)
Text(
stringResource(CommonStrings.common_editing),
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.weight(1f)
)
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp)
.size(16.dp.applyScaleUp())
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}
@Composable
private fun ReplyToModeView(
senderName: String,
text: String?,
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.clip(RoundedCornerShape(13.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp)
) {
if (attachmentThumbnailInfo != null) {
AttachmentThumbnail(
info = attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(9.dp))
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
) {
Text(
text = senderName,
modifier = Modifier.fillMaxWidth(),
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.primary,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
maxLines = if (attachmentThumbnailInfo != null) 1 else 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(CommonStrings.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp)
.size(16.dp.applyScaleUp())
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}
@Composable
private fun SendButton(
canSendMessage: Boolean,
onClick: () -> Unit,
composerMode: MessageComposerMode,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier
.size(48.dp.applyScaleUp()),
onClick = onClick,
enabled = canSendMessage,
) {
val iconId = when (composerMode) {
is MessageComposerMode.Edit -> R.drawable.ic_tick
else -> R.drawable.ic_send
}
val contentDescription = when (composerMode) {
is MessageComposerMode.Edit -> stringResource(CommonStrings.action_edit)
else -> stringResource(CommonStrings.action_send)
}
Box(
modifier = Modifier
.clip(CircleShape)
.size(36.dp.applyScaleUp())
.background(if (canSendMessage) ElementTheme.colors.iconAccentTertiary else Color.Transparent)
) {
Icon(
modifier = Modifier
.height(18.dp.applyScaleUp())
.align(Alignment.Center),
resourceId = iconId,
contentDescription = contentDescription,
// Exception here, we use Color.White instead of ElementTheme.colors.iconOnSolidPrimary
tint = if (canSendMessage) Color.White else ElementTheme.colors.iconDisabled
)
}
}
}
@DayNightPreviews
@Composable
internal fun TextComposerSimplePreview() = ElementPreview {
Column {
TextComposer(
RichTextEditorState("", fake = true).apply { requestFocus() },
canSendMessage = false,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
)
TextComposer(
RichTextEditorState("A message", fake = true).apply { requestFocus() },
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
)
TextComposer(
RichTextEditorState(
"A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
fake = true
).apply {
requestFocus()
},
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
)
TextComposer(
RichTextEditorState("A message without focus", fake = true),
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
)
}
}
@DayNightPreviews
@Composable
internal fun TextComposerFormattingPreview() = ElementPreview {
Column {
TextComposer(
RichTextEditorState("", fake = true),
canSendMessage = false,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""),
)
TextComposer(
RichTextEditorState("A message", fake = true),
canSendMessage = true,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""),
)
TextComposer(
RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", fake = true),
canSendMessage = true,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""),
)
}
}
@DayNightPreviews
@Composable
internal fun TextComposerEditPreview() = ElementPreview {
TextComposer(
RichTextEditorState("A message", fake = true).apply { requestFocus() },
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
onResetComposerMode = {},
)
}
@DayNightPreviews
@Composable
internal fun TextComposerReplyPreview() = ElementPreview {
Column {
TextComposer(
RichTextEditorState("", fake = true),
canSendMessage = false,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = null,
defaultContent = "A message\n" +
"With several lines\n" +
"To preview larger textfields and long lines with overflow"
),
onResetComposerMode = {},
)
TextComposer(
RichTextEditorState("A message", fake = true),
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = MediaSource("https://domain.com/image.jpg"),
textContent = "image.jpg",
type = AttachmentThumbnailType.Image,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
),
defaultContent = "image.jpg"
),
onResetComposerMode = {},
)
TextComposer(
RichTextEditorState("A message", fake = true),
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = MediaSource("https://domain.com/video.mp4"),
textContent = "video.mp4",
type = AttachmentThumbnailType.Video,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
),
defaultContent = "video.mp4"
),
onResetComposerMode = {},
)
TextComposer(
RichTextEditorState("A message", fake = true),
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "logs.txt",
type = AttachmentThumbnailType.File,
blurHash = null,
),
defaultContent = "logs.txt"
),
onResetComposerMode = {},
)
TextComposer(
RichTextEditorState("A message", fake = true).apply { requestFocus() },
canSendMessage = true,
onSendMessage = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = null,
type = AttachmentThumbnailType.Location,
blurHash = null,
),
defaultContent = "Shared location"
),
onResetComposerMode = {},
)
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.text.applyScaleUp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.theme.compound.generated.SemanticColors
@Composable
internal fun FormattingOption(
state: FormattingOptionState,
onClick: () -> Unit,
imageVector: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
colors: SemanticColors = ElementTheme.colors,
) {
val backgroundColor = when (state) {
FormattingOptionState.Selected -> colors.bgActionPrimaryRest
FormattingOptionState.Default,
FormattingOptionState.Disabled -> Color.Transparent
}
val foregroundColor = when (state) {
FormattingOptionState.Selected -> colors.iconOnSolidPrimary
FormattingOptionState.Default -> colors.iconPrimary
FormattingOptionState.Disabled -> colors.iconDisabled
}
Box(
modifier = modifier
.clickable { onClick() }
.size(44.dp.applyScaleUp())
.background(backgroundColor, shape = RoundedCornerShape(4.dp.applyScaleUp()))
) {
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = imageVector,
contentDescription = contentDescription,
tint = foregroundColor,
)
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.components
internal enum class FormattingOptionState {
Default, Selected, Disabled
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M16,18.121L19.182,21.303C19.483,21.604 19.836,21.754 20.243,21.754C20.649,21.754 21.003,21.604 21.303,21.303C21.604,21.003 21.754,20.649 21.754,20.243C21.754,19.836 21.604,19.483 21.303,19.182L18.121,16L21.303,12.818C21.604,12.517 21.754,12.164 21.754,11.757C21.754,11.351 21.604,10.997 21.303,10.697C21.003,10.396 20.649,10.246 20.243,10.246C19.836,10.246 19.483,10.396 19.182,10.697L16,13.879L12.818,10.697C12.517,10.396 12.164,10.246 11.757,10.246C11.351,10.246 10.997,10.396 10.697,10.697C10.396,10.997 10.246,11.351 10.246,11.757C10.246,12.164 10.396,12.517 10.697,12.818L13.879,16L10.697,19.182C10.396,19.483 10.246,19.836 10.246,20.243C10.246,20.649 10.396,21.003 10.697,21.303C10.997,21.604 11.351,21.754 11.757,21.754C12.164,21.754 12.517,21.604 12.818,21.303L16,18.121ZM26.607,26.607C25.139,28.074 23.482,29.174 21.635,29.908C19.787,30.642 17.909,31.008 16,31.008C14.091,31.008 12.213,30.642 10.365,29.908C8.518,29.174 6.861,28.074 5.393,26.607C3.926,25.139 2.826,23.482 2.092,21.635C1.358,19.787 0.992,17.909 0.992,16C0.992,14.091 1.358,12.213 2.092,10.365C2.826,8.518 3.926,6.861 5.393,5.393C6.861,3.926 8.518,2.826 10.365,2.092C12.213,1.358 14.091,0.992 16,0.992C17.909,0.992 19.787,1.358 21.635,2.092C23.482,2.826 25.139,3.926 26.607,5.393C28.074,6.861 29.174,8.518 29.908,10.365C30.642,12.213 31.008,14.091 31.008,16C31.008,17.909 30.642,19.787 29.908,21.635C29.174,23.482 28.074,25.139 26.607,26.607Z"
android:fillColor="#1B1D22"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="30dp"
android:height="30dp"
android:viewportWidth="30"
android:viewportHeight="30">
<path
android:pathData="M13.5,16.5V21C13.5,21.425 13.644,21.781 13.931,22.069C14.219,22.356 14.575,22.5 15,22.5C15.425,22.5 15.781,22.356 16.069,22.069C16.356,21.781 16.5,21.425 16.5,21V16.5H21C21.425,16.5 21.781,16.356 22.069,16.069C22.356,15.781 22.5,15.425 22.5,15C22.5,14.575 22.356,14.219 22.069,13.931C21.781,13.644 21.425,13.5 21,13.5H16.5V9C16.5,8.575 16.356,8.219 16.069,7.931C15.781,7.644 15.425,7.5 15,7.5C14.575,7.5 14.219,7.644 13.931,7.931C13.644,8.219 13.5,8.575 13.5,9V13.5H9C8.575,13.5 8.219,13.644 7.931,13.931C7.644,14.219 7.5,14.575 7.5,15C7.5,15.425 7.644,15.781 7.931,16.069C8.219,16.356 8.575,16.5 9,16.5H13.5ZM15,30C12.925,30 10.975,29.606 9.15,28.819C7.325,28.031 5.738,26.962 4.387,25.612C3.037,24.263 1.969,22.675 1.181,20.85C0.394,19.025 0,17.075 0,15C0,12.925 0.394,10.975 1.181,9.15C1.969,7.325 3.037,5.738 4.387,4.387C5.738,3.037 7.325,1.969 9.15,1.181C10.975,0.394 12.925,0 15,0C17.075,0 19.025,0.394 20.85,1.181C22.675,1.969 24.263,3.037 25.612,4.387C26.962,5.738 28.031,7.325 28.819,9.15C29.606,10.975 30,12.925 30,15C30,17.075 29.606,19.025 28.819,20.85C28.031,22.675 26.962,24.263 25.612,25.612C24.263,26.962 22.675,28.031 20.85,28.819C19.025,29.606 17.075,30 15,30Z"
android:fillColor="#1B1D22"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="21dp"
android:height="18dp"
android:viewportWidth="21"
android:viewportHeight="18">
<path
android:pathData="M20.252,10.085 L4.681,17.867c-1.049,0.525 -2.141,-0.601 -1.628,-1.627 0,0 1.93,-3.897 2.461,-4.918 0.531,-1.021 1.138,-1.197 6.781,-1.927 0.209,-0.027 0.38,-0.185 0.38,-0.395 0,-0.21 -0.171,-0.368 -0.38,-0.395C6.652,7.876 6.045,7.699 5.514,6.678 4.983,5.658 3.053,1.76 3.053,1.76 2.54,0.734 3.632,-0.391 4.681,0.133L20.252,7.915c0.894,0.446 0.894,1.723 0,2.17z"
android:fillColor="@android:color/white"/>
</vector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Přidat přílohu"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Anhang hinzufügen"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Adăugați un atașament"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Прикрепить файл"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Pridať prílohu"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"新增附件"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Add attachment"</string>
</resources>

View file

@ -1,567 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.modifiers.applyIf
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.applyScaleUp
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.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.android.awaitFrame
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun TextComposer(
composerText: String?,
composerMode: MessageComposerMode,
composerCanSendMessage: Boolean,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = FocusRequester(),
onSendMessage: (String) -> Unit = {},
onResetComposerMode: () -> Unit = {},
onComposerTextChange: (String) -> Unit = {},
onAddAttachment: () -> Unit = {},
onFocusChanged: (Boolean) -> Unit = {},
) {
val text = composerText.orEmpty()
Row(
modifier.padding(
horizontal = 12.dp,
vertical = 8.dp
), verticalAlignment = Alignment.Bottom
) {
AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp))
Spacer(modifier = Modifier.width(12.dp))
val roundCornerSmall = 20.dp.applyScaleUp()
val roundCornerLarge = 28.dp.applyScaleUp()
var lineCount by remember { mutableIntStateOf(0) }
val roundedCornerSize = remember(lineCount, composerMode) {
if (lineCount > 1 || composerMode is MessageComposerMode.Special) {
roundCornerSmall
} else {
roundCornerLarge
}
}
val roundedCornerSizeState = animateDpAsState(
targetValue = roundedCornerSize,
animationSpec = tween(
durationMillis = 100,
)
)
val roundedCorners = RoundedCornerShape(roundedCornerSizeState.value)
val minHeight = 42.dp.applyScaleUp()
val bgColor = ElementTheme.colors.bgSubtleSecondary
// Change border color depending on focus
var hasFocus by remember { mutableStateOf(false) }
val borderColor = if (hasFocus) ElementTheme.colors.borderDisabled else bgColor
Column(
modifier = Modifier
.fillMaxWidth()
.clip(roundedCorners)
.background(color = bgColor)
.border(1.dp, borderColor, roundedCorners)
) {
if (composerMode is MessageComposerMode.Special) {
ComposerModeView(composerMode = composerMode, onResetComposerMode = onResetComposerMode)
}
val defaultTypography = ElementTheme.typography.fontBodyLgRegular
Box {
BasicTextField(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.focusRequester(focusRequester)
.onFocusEvent {
hasFocus = it.hasFocus
onFocusChanged(it.hasFocus)
},
value = text,
onValueChange = { onComposerTextChange(it) },
onTextLayout = {
lineCount = it.lineCount
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary),
cursorBrush = SolidColor(ElementTheme.colors.iconAccentTertiary),
decorationBox = { innerTextField ->
TextFieldDefaults.DecorationBox(
value = text,
innerTextField = innerTextField,
enabled = true,
singleLine = false,
visualTransformation = VisualTransformation.None,
shape = roundedCorners,
contentPadding = PaddingValues(
top = 10.dp.applyScaleUp(),
bottom = 10.dp.applyScaleUp(),
start = 12.dp.applyScaleUp(),
end = 42.dp.applyScaleUp(),
),
interactionSource = remember { MutableInteractionSource() },
placeholder = {
Text(stringResource(CommonStrings.common_message), style = defaultTypography)
},
colors = TextFieldDefaults.colors(
unfocusedTextColor = MaterialTheme.colorScheme.secondary,
focusedTextColor = MaterialTheme.colorScheme.primary,
unfocusedPlaceholderColor = ElementTheme.colors.textDisabled,
focusedPlaceholderColor = ElementTheme.colors.textDisabled,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
unfocusedContainerColor = bgColor,
focusedContainerColor = bgColor,
errorContainerColor = bgColor,
disabledContainerColor = bgColor,
)
)
}
)
SendButton(
text = text,
canSendMessage = composerCanSendMessage,
onSendMessage = onSendMessage,
composerMode = composerMode,
modifier = Modifier.padding(end = 6.dp.applyScaleUp(), bottom = 6.dp.applyScaleUp())
)
}
}
}
// Request focus when changing mode, and show keyboard.
val keyboard = LocalSoftwareKeyboardController.current
LaunchedEffect(composerMode) {
if (composerMode is MessageComposerMode.Special) {
focusRequester.requestFocus()
keyboard?.let {
awaitFrame()
it.show()
}
}
}
}
@Composable
private fun ComposerModeView(
composerMode: MessageComposerMode,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
when (composerMode) {
is MessageComposerMode.Edit -> {
EditingModeView(onResetComposerMode = onResetComposerMode, modifier = modifier)
}
is MessageComposerMode.Reply -> {
ReplyToModeView(
modifier = modifier.padding(8.dp),
senderName = composerMode.senderName,
text = composerMode.defaultContent.toString(),
attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo,
onResetComposerMode = onResetComposerMode,
)
}
else -> Unit
}
}
@Composable
private fun EditingModeView(
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(start = 12.dp)
) {
Icon(
resourceId = VectorIcons.Edit,
contentDescription = stringResource(CommonStrings.common_editing),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.size(16.dp.applyScaleUp()),
)
Text(
stringResource(CommonStrings.common_editing),
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(vertical = 8.dp)
.weight(1f)
)
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(CommonStrings.action_close),
tint = ElementTheme.materialColors.secondary,
modifier = Modifier
.padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp)
.size(16.dp.applyScaleUp())
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}
@Composable
private fun ReplyToModeView(
senderName: String,
text: String?,
attachmentThumbnailInfo: AttachmentThumbnailInfo?,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.clip(RoundedCornerShape(13.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(4.dp)
) {
if (attachmentThumbnailInfo != null) {
AttachmentThumbnail(
info = attachmentThumbnailInfo,
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(9.dp))
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
) {
Text(
text = senderName,
modifier = Modifier.fillMaxWidth(),
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.primary,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
maxLines = if (attachmentThumbnailInfo != null) 1 else 2,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(CommonStrings.action_close),
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp)
.size(16.dp.applyScaleUp())
.clickable(
enabled = true,
onClick = onResetComposerMode,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false)
),
)
}
}
@Composable
private fun AttachmentButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier
.size(30.dp.applyScaleUp())
.clickable(onClick = onClick),
shape = CircleShape,
color = ElementTheme.colors.iconPrimary
) {
Image(
modifier = Modifier.size(12.5f.dp.applyScaleUp()),
painter = painterResource(R.drawable.ic_add_attachment),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
contentScale = ContentScale.Inside,
colorFilter = ColorFilter.tint(
LocalContentColor.current
)
)
}
}
@Composable
private fun BoxScope.SendButton(
text: String,
canSendMessage: Boolean,
onSendMessage: (String) -> Unit,
composerMode: MessageComposerMode,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.clip(CircleShape)
.background(if (canSendMessage) ElementTheme.colors.iconAccentTertiary else Color.Transparent)
.size(30.dp.applyScaleUp())
.align(Alignment.BottomEnd)
.applyIf(composerMode !is MessageComposerMode.Edit, ifTrue = {
padding(start = 1.dp.applyScaleUp()) // Center the arrow in the circle
})
.clickable(
enabled = canSendMessage,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false),
onClick = {
onSendMessage(text)
}),
contentAlignment = Alignment.Center,
) {
val iconId = when (composerMode) {
is MessageComposerMode.Edit -> R.drawable.ic_tick
else -> R.drawable.ic_send
}
val contentDescription = when (composerMode) {
is MessageComposerMode.Edit -> stringResource(CommonStrings.action_edit)
else -> stringResource(CommonStrings.action_send)
}
Icon(
modifier = Modifier.size(16.dp.applyScaleUp()),
resourceId = iconId,
contentDescription = contentDescription,
// Exception here, we use Color.White instead of ElementTheme.colors.iconOnSolidPrimary
tint = if (canSendMessage) Color.White else ElementTheme.colors.iconDisabled
)
}
}
@DayNightPreviews
@Composable
internal fun TextComposerSimplePreview() = ElementPreview {
Column {
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = false,
composerText = "",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Normal(""),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
)
}
}
@DayNightPreviews
@Composable
internal fun TextComposerEditPreview() = ElementPreview {
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
}
@DayNightPreviews
@Composable
internal fun TextComposerReplyPreview() = ElementPreview {
Column {
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = null,
defaultContent = "A message\n" +
"With several lines\n" +
"To preview larger textfields and long lines with overflow"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = MediaSource("https://domain.com/image.jpg"),
textContent = "image.jpg",
type = AttachmentThumbnailType.Image,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
),
defaultContent = "image.jpg"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = MediaSource("https://domain.com/video.mp4"),
textContent = "video.mp4",
type = AttachmentThumbnailType.Video,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
),
defaultContent = "video.mp4"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = "logs.txt",
type = AttachmentThumbnailType.File,
blurHash = null,
),
defaultContent = "logs.txt"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
TextComposer(
onSendMessage = {},
onComposerTextChange = {},
composerMode = MessageComposerMode.Reply(
senderName = "Alice",
eventId = EventId("$1234"),
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = null,
textContent = null,
type = AttachmentThumbnailType.Location,
blurHash = null,
),
defaultContent = "Shared location"
),
onResetComposerMode = {},
composerCanSendMessage = true,
composerText = "A message",
)
}
}

View file

@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,773Q457.91,773 441.46,756.54Q425,740.09 425,718.31L425,535L241.69,535Q219.91,535 203.46,518.54Q187,502.09 187,480Q187,457.91 203.46,441.46Q219.91,425 241.69,425L425,425L425,241.69Q425,219.91 441.46,203.46Q457.91,187 480,187Q502.09,187 518.54,203.46Q535,219.91 535,241.69L535,425L718.31,425Q740.09,425 756.54,441.46Q773,457.91 773,480Q773,502.09 756.54,518.54Q740.09,535 718.31,535L535,535L535,718.31Q535,740.09 518.54,756.54Q502.09,773 480,773Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M15.404,8.965L1.563,15.882C0.631,16.348 -0.34,15.348 0.116,14.435C0.116,14.435 1.832,10.971 2.303,10.064C2.775,9.156 3.315,8.999 8.331,8.351C8.517,8.327 8.669,8.187 8.669,8C8.669,7.813 8.517,7.673 8.331,7.649C3.315,7.001 2.775,6.844 2.303,5.936C1.832,5.029 0.116,1.565 0.116,1.565C-0.34,0.653 0.631,-0.348 1.563,0.118L15.404,7.036C16.199,7.433 16.199,8.567 15.404,8.965Z"
android:fillColor="#A6ADB7"/>
</vector>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Přidat přílohu"</string>
<string name="rich_text_editor_bullet_list">"Přepnout seznam s odrážkami"</string>
<string name="rich_text_editor_code_block">"Přepnout blok kódu"</string>
<string name="rich_text_editor_composer_placeholder">"Zpráva…"</string>
<string name="rich_text_editor_format_bold">"Použít tučný text"</string>
<string name="rich_text_editor_format_italic">"Použít kurzívu"</string>
<string name="rich_text_editor_format_strikethrough">"Použít přeškrtnutí"</string>
<string name="rich_text_editor_format_underline">"Použít podtržení"</string>
<string name="rich_text_editor_full_screen_toggle">"Přepnout režim celé obrazovky"</string>
<string name="rich_text_editor_indent">"Odsazení"</string>
<string name="rich_text_editor_inline_code">"Použít formát inline kódu"</string>
<string name="rich_text_editor_link">"Nastavit odkaz"</string>
<string name="rich_text_editor_numbered_list">"Přepnout číslovaný seznam"</string>
<string name="rich_text_editor_quote">"Přepnout citaci"</string>
<string name="rich_text_editor_unindent">"Zrušit odsazení"</string>
</resources>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Anhang hinzufügen"</string>
<string name="rich_text_editor_bullet_list">"Aufzählungsliste ein-/ausschalten"</string>
<string name="rich_text_editor_code_block">"Codeblock umschalten"</string>
<string name="rich_text_editor_composer_placeholder">"Nachricht…"</string>
<string name="rich_text_editor_format_bold">"Fettformatierung anwenden"</string>
<string name="rich_text_editor_format_italic">"Kursivformat anwenden"</string>
<string name="rich_text_editor_format_strikethrough">"Durchgestrichenes Format anwenden"</string>
<string name="rich_text_editor_format_underline">"Unterstreichungsformat anwenden"</string>
<string name="rich_text_editor_full_screen_toggle">"Vollbildmodus umschalten"</string>
<string name="rich_text_editor_indent">"Einrücken"</string>
<string name="rich_text_editor_inline_code">"Inline-Codeformat anwenden"</string>
<string name="rich_text_editor_link">"Link setzen"</string>
<string name="rich_text_editor_numbered_list">"Nummerierte Liste ein-/ausschalten"</string>
<string name="rich_text_editor_quote">"Zitat umschalten"</string>
<string name="rich_text_editor_unindent">"Einrücken aufheben"</string>
</resources>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_bullet_list">"Lista de puntos"</string>
<string name="rich_text_editor_code_block">"Bloque de código"</string>
<string name="rich_text_editor_composer_placeholder">"Mensaje…"</string>
<string name="rich_text_editor_format_bold">"Aplicar formato negrita"</string>
<string name="rich_text_editor_format_italic">"Aplicar formato cursiva"</string>
<string name="rich_text_editor_format_strikethrough">"Aplicar formato tachado"</string>
<string name="rich_text_editor_format_underline">"Aplicar formato de subrayado"</string>
<string name="rich_text_editor_full_screen_toggle">"Pantalla completa"</string>
<string name="rich_text_editor_indent">"Añadir sangría"</string>
<string name="rich_text_editor_inline_code">"Código"</string>
<string name="rich_text_editor_link">"Enlazar"</string>
<string name="rich_text_editor_numbered_list">"Lista numérica"</string>
<string name="rich_text_editor_quote">"Cita"</string>
<string name="rich_text_editor_unindent">"Quitar sangría"</string>
</resources>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_bullet_list">"Afficher une liste à puces"</string>
<string name="rich_text_editor_code_block">"Afficher le bloc de code"</string>
<string name="rich_text_editor_composer_placeholder">"Envoyer un message…"</string>
<string name="rich_text_editor_format_bold">"Appliquer le format gras"</string>
<string name="rich_text_editor_format_italic">"Appliquer le format italique"</string>
<string name="rich_text_editor_format_strikethrough">"Appliquer le format barré"</string>
<string name="rich_text_editor_format_underline">"Appliquer le format souligné"</string>
<string name="rich_text_editor_full_screen_toggle">"Afficher en mode plein écran"</string>
<string name="rich_text_editor_indent">"Décaler vers la droite"</string>
<string name="rich_text_editor_inline_code">"Appliquer le formatage de code en ligne"</string>
<string name="rich_text_editor_link">"Définir un lien"</string>
<string name="rich_text_editor_numbered_list">"Afficher une liste numérotée"</string>
<string name="rich_text_editor_quote">"Afficher une citation"</string>
<string name="rich_text_editor_unindent">"Décaler vers la gauche"</string>
</resources>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_bullet_list">"Attiva/disattiva l\'elenco puntato"</string>
<string name="rich_text_editor_code_block">"Attiva/disattiva il blocco di codice"</string>
<string name="rich_text_editor_composer_placeholder">"Messaggio…"</string>
<string name="rich_text_editor_format_bold">"Applica il formato in grassetto"</string>
<string name="rich_text_editor_format_italic">"Applicare il formato corsivo"</string>
<string name="rich_text_editor_format_strikethrough">"Applica il formato barrato"</string>
<string name="rich_text_editor_format_underline">"Applicare il formato di sottolineatura"</string>
<string name="rich_text_editor_full_screen_toggle">"Attiva/disattiva la modalità a schermo intero"</string>
<string name="rich_text_editor_indent">"Rientro a destra"</string>
<string name="rich_text_editor_inline_code">"Applicare il formato del codice in linea"</string>
<string name="rich_text_editor_link">"Imposta collegamento"</string>
<string name="rich_text_editor_numbered_list">"Attiva/disattiva elenco numerato"</string>
<string name="rich_text_editor_quote">"Attiva/disattiva citazione"</string>
<string name="rich_text_editor_unindent">"Rientro a sinistra"</string>
</resources>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_bullet_list">"Comutați lista cu puncte"</string>
<string name="rich_text_editor_code_block">"Comutați blocul de cod"</string>
<string name="rich_text_editor_composer_placeholder">"Mesaj…"</string>
<string name="rich_text_editor_format_bold">"Aplicați formatul aldin"</string>
<string name="rich_text_editor_format_italic">"Aplicați formatul italic"</string>
<string name="rich_text_editor_format_strikethrough">"Aplicați formatul barat"</string>
<string name="rich_text_editor_format_underline">"Aplică formatul de subliniere"</string>
<string name="rich_text_editor_full_screen_toggle">"Comutați modul ecran complet"</string>
<string name="rich_text_editor_indent">"Indentare"</string>
<string name="rich_text_editor_inline_code">"Aplicați formatul de cod inline"</string>
<string name="rich_text_editor_link">"Setați linkul"</string>
<string name="rich_text_editor_numbered_list">"Comutați lista numerotată"</string>
<string name="rich_text_editor_quote">"Aplicați citatul"</string>
<string name="rich_text_editor_unindent">"Dez-identare"</string>
</resources>

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