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:
commit
c3fbac4678
686 changed files with 7212 additions and 2257 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
9
libraries/designsystem/src/main/res/drawable/ic_bold.xml
Normal file
9
libraries/designsystem/src/main/res/drawable/ic_bold.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
9
libraries/designsystem/src/main/res/drawable/ic_link.xml
Normal file
9
libraries/designsystem/src/main/res/drawable/ic_link.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
9
libraries/designsystem/src/main/res/drawable/ic_poll.xml
Normal file
9
libraries/designsystem/src/main/res/drawable/ic_poll.xml
Normal 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>
|
||||
21
libraries/designsystem/src/main/res/drawable/ic_poll_end.xml
Normal file
21
libraries/designsystem/src/main/res/drawable/ic_poll_end.xml
Normal 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>
|
||||
18
libraries/designsystem/src/main/res/drawable/ic_quote.xml
Normal file
18
libraries/designsystem/src/main/res/drawable/ic_quote.xml
Normal 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>
|
||||
25
libraries/designsystem/src/main/res/drawable/ic_search.xml
Normal file
25
libraries/designsystem/src/main/res/drawable/ic_search.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -31,4 +31,5 @@ dependencies {
|
|||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
28
libraries/permissions/test/build.gradle.kts
Normal file
28
libraries/permissions/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)!!
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue