Merge branch 'develop' into feature/fga/room_navigation
This commit is contained in:
commit
d07df2319b
447 changed files with 3318 additions and 1374 deletions
|
|
@ -34,6 +34,7 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.R
|
||||
import io.element.android.libraries.designsystem.modifiers.blurCompat
|
||||
import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow
|
||||
|
|
@ -171,6 +172,7 @@ internal fun ElementLogoAtomLargeNoBlurShadowPreview() = ElementPreview {
|
|||
ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false)
|
||||
}
|
||||
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) {
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ fun InfoListItemMolecule(
|
|||
color = backgroundColor,
|
||||
shape = backgroundShape,
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
.padding(vertical = 12.dp, horizontal = 18.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
icon()
|
||||
message()
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
|
||||
/**
|
||||
* @param modifier Classical modifier.
|
||||
* @param background optional background component.
|
||||
* @param topBar optional topBar.
|
||||
* @param header optional header.
|
||||
* @param footer optional footer.
|
||||
|
|
@ -42,6 +43,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
@Composable
|
||||
fun HeaderFooterPage(
|
||||
modifier: Modifier = Modifier,
|
||||
background: @Composable () -> Unit = {},
|
||||
topBar: @Composable () -> Unit = {},
|
||||
header: @Composable () -> Unit = {},
|
||||
footer: @Composable () -> Unit = {},
|
||||
|
|
@ -51,25 +53,28 @@ fun HeaderFooterPage(
|
|||
modifier = modifier,
|
||||
topBar = topBar,
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.padding(all = 20.dp),
|
||||
) {
|
||||
// Header
|
||||
header()
|
||||
// Content
|
||||
Box {
|
||||
background()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
.padding(all = 20.dp)
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
// Footer
|
||||
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
footer()
|
||||
// Header
|
||||
header()
|
||||
// Content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
// Footer
|
||||
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
footer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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 androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.LinearGradientShader
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
||||
/**
|
||||
* Gradient background for FTUE (onboarding) screens.
|
||||
*/
|
||||
@Suppress("ModifierMissing")
|
||||
@Composable
|
||||
fun OnboardingBackground() {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val isLightTheme = ElementTheme.isLightTheme
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
val gradientBrush = ShaderBrush(
|
||||
LinearGradientShader(
|
||||
from = Offset(0f, size.height / 2f),
|
||||
to = Offset(size.width, size.height / 2f),
|
||||
colors = listOf(
|
||||
Color(0xFF0DBDA8),
|
||||
if (isLightTheme) Color(0xC90D5CBD) else Color(0xFF0D5CBD),
|
||||
)
|
||||
)
|
||||
)
|
||||
val eraseBrush = ShaderBrush(
|
||||
LinearGradientShader(
|
||||
from = Offset(size.width / 2f, 0f),
|
||||
to = Offset(size.width / 2f, size.height * 2f),
|
||||
colors = listOf(
|
||||
Color(0xFF000000),
|
||||
Color(0x00000000),
|
||||
)
|
||||
)
|
||||
)
|
||||
drawWithLayer {
|
||||
drawRect(brush = gradientBrush, size = size)
|
||||
drawRect(brush = gradientBrush, size = size, blendMode = BlendMode.Overlay)
|
||||
drawRect(brush = eraseBrush, size = size, blendMode = BlendMode.DstOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun OnboardingBackgroundPreview() {
|
||||
ElementPreview {
|
||||
OnboardingBackground()
|
||||
}
|
||||
}
|
||||
|
|
@ -23,16 +23,21 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
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 coil.compose.AsyncImagePainter
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.compose.SubcomposeAsyncImageContent
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
|
|
@ -71,16 +76,34 @@ private fun ImageAvatar(
|
|||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = avatarData,
|
||||
onError = {
|
||||
Timber.e(it.result.throwable, "Error loading avatar $it\n${it.result}")
|
||||
},
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = debugPlaceholderAvatar(),
|
||||
modifier = modifier
|
||||
)
|
||||
if (LocalInspectionMode.current) {
|
||||
// For compose previews, use debugPlaceholderAvatar()
|
||||
// instead of falling back to initials avatar on load failure
|
||||
AsyncImage(
|
||||
model = avatarData,
|
||||
contentDescription = contentDescription,
|
||||
placeholder = debugPlaceholderAvatar(),
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
SubcomposeAsyncImage(
|
||||
model = avatarData,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier
|
||||
) {
|
||||
when (val state = painter.state) {
|
||||
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
SideEffect {
|
||||
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
|
||||
}
|
||||
InitialsAvatar(avatarData = avatarData)
|
||||
}
|
||||
else -> InitialsAvatar(avatarData = avatarData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
|
|
@ -162,6 +163,7 @@ internal fun PreferenceTextWithEndBadgeDarkPreview() = ElementPreviewDark {
|
|||
ContentToPreview(showEndBadge = true)
|
||||
}
|
||||
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview(showEndBadge: Boolean) {
|
||||
Column(
|
||||
|
|
|
|||
|
|
@ -1,107 +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.designsystem.modifiers
|
||||
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// Note: these modifiers come from https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
|
||||
|
||||
/**
|
||||
* A modifier that clips the composable content using an animated circle. The circle will
|
||||
* expand/shrink with an animation whenever [visible] changes.
|
||||
*
|
||||
* For more fine-grained control over the transition, see this method's overload, which allows passing
|
||||
* a [State] object to control the progress of the reveal animation.
|
||||
*
|
||||
* By default, the circle is centered in the content, but custom positions may be specified using
|
||||
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).*/
|
||||
fun Modifier.circularReveal(
|
||||
visible: Boolean,
|
||||
showScrim: Boolean = false,
|
||||
revealFrom: Offset = Offset(0.5f, 0.5f),
|
||||
): Modifier = composed(
|
||||
factory = {
|
||||
val factor = updateTransition(visible, label = "Visibility")
|
||||
.animateFloat(label = "revealFactor") { if (it) 1f else 0f }
|
||||
|
||||
circularReveal(factor, showScrim, revealFrom)
|
||||
},
|
||||
inspectorInfo = debugInspectorInfo {
|
||||
name = "circularReveal"
|
||||
properties["visible"] = visible
|
||||
properties["revealFrom"] = revealFrom
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* A modifier that clips the composable content using a circular shape. The radius of the circle
|
||||
* will be determined by the [transitionProgress].
|
||||
*
|
||||
* The values of the progress should be between 0 and 1.
|
||||
*
|
||||
* By default, the circle is centered in the content, but custom positions may be specified using
|
||||
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
|
||||
* */
|
||||
fun Modifier.circularReveal(
|
||||
transitionProgress: State<Float>,
|
||||
showScrim: Boolean = false,
|
||||
revealFrom: Offset = Offset(0.5f, 0.5f)
|
||||
): Modifier {
|
||||
return drawWithCache {
|
||||
val path = Path()
|
||||
val center = revealFrom.mapTo(size)
|
||||
val radius = calculateRadius(revealFrom, size)
|
||||
val scrimColor = if (showScrim) {
|
||||
Color.Gray
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
path.addOval(Rect(center, radius * transitionProgress.value))
|
||||
|
||||
onDrawWithContent {
|
||||
if (showScrim) {
|
||||
drawRect(scrimColor, alpha = transitionProgress.value * 0.75f)
|
||||
}
|
||||
clipPath(path) { this@onDrawWithContent.drawContent() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.mapTo(size: Size): Offset {
|
||||
return Offset(x * size.width, y * size.height)
|
||||
}
|
||||
|
||||
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
|
||||
val x = (if (x > 0.5f) x else 1 - x) * size.width
|
||||
val y = (if (y > 0.5f) y else 1 - y) * size.height
|
||||
|
||||
sqrt(x * x + y * y)
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.modifiers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.LayoutModifier
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.platform.InspectorValueInfo
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Makes the content square in size.
|
||||
*
|
||||
* This is achieved by cropping incoming max constraints to the largest possible square size
|
||||
* and measuring the content using resulting constraints.
|
||||
* Next the size of layout is decided based on largest dimension of the measured content.
|
||||
* Finally the content is placed inside the square layout according to specified [position].
|
||||
*
|
||||
* If no square exists that falls within the size range of the incoming constraints,
|
||||
* the content will be laid out as usual, as if the modifier was not applied.
|
||||
*
|
||||
* @param position The fraction of the content's position inside its square layout.
|
||||
* It determines the point on the axis that was extended to make a square.
|
||||
* Typically you'd want to use values between `0` and `1`, inclusive, where `0`
|
||||
* will place the content at the "start" of the square, `0.5` in the middle, and `1` at the "end".
|
||||
*/
|
||||
@Stable
|
||||
fun Modifier.squareSize(
|
||||
position: Float = 0.5f,
|
||||
): Modifier =
|
||||
this.then(
|
||||
when {
|
||||
position == 0.5f -> SquareSizeCenter
|
||||
else -> createSquareSizeModifier(position = position)
|
||||
}
|
||||
)
|
||||
|
||||
private val SquareSizeCenter = createSquareSizeModifier(position = 0.5f)
|
||||
|
||||
private class SquareSizeModifier(
|
||||
private val position: Float,
|
||||
inspectorInfo: InspectorInfo.() -> Unit,
|
||||
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
|
||||
override fun MeasureScope.measure(
|
||||
measurable: Measurable,
|
||||
constraints: Constraints,
|
||||
): MeasureResult {
|
||||
val maxSquare = min(constraints.maxWidth, constraints.maxHeight)
|
||||
val minSquare = max(constraints.minWidth, constraints.minHeight)
|
||||
val squareExists = minSquare <= maxSquare
|
||||
|
||||
val resolvedConstraints = constraints
|
||||
.takeUnless { squareExists }
|
||||
?: constraints.copy(maxWidth = maxSquare, maxHeight = maxSquare)
|
||||
|
||||
val placeable = measurable.measure(resolvedConstraints)
|
||||
|
||||
return if (squareExists) {
|
||||
val size = max(placeable.width, placeable.height)
|
||||
layout(size, size) {
|
||||
val x = ((size - placeable.width) * position).toInt()
|
||||
val y = ((size - placeable.height) * position).toInt()
|
||||
placeable.placeRelative(x, y)
|
||||
}
|
||||
} else {
|
||||
layout(placeable.width, placeable.height) {
|
||||
placeable.placeRelative(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
if (other !is SquareSizeModifier) return false
|
||||
|
||||
if (position != other.position) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return position.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ModifierFactoryExtensionFunction", "ModifierFactoryReturnType")
|
||||
private fun createSquareSizeModifier(
|
||||
position: Float,
|
||||
) =
|
||||
SquareSizeModifier(
|
||||
position = position,
|
||||
inspectorInfo = debugInspectorInfo {
|
||||
name = "squareSize"
|
||||
properties["position"] = position
|
||||
},
|
||||
)
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun SquareSizeModifierLargeWidthPreview() {
|
||||
ElementPreview {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(32.dp)
|
||||
.background(Color.Gray)
|
||||
.squareSize(position = 0.25f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color.Black)
|
||||
.size(100.dp, 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun SquareSizeModifierLargeHeightPreview() {
|
||||
ElementPreview {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(32.dp)
|
||||
.background(Color.Gray)
|
||||
.squareSize(position = 0.75f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color.Black)
|
||||
.size(10.dp, 100.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun SquareSizeModifierInsideSquarePreview() {
|
||||
ElementPreview {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(32.dp)
|
||||
.size(120.dp)
|
||||
.background(Color.Gray),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color.Black)
|
||||
.width(100.dp)
|
||||
.squareSize(position = 0.75f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
|
|
@ -269,6 +270,7 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
|
|||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ExcludeFromCoverage
|
||||
private fun ContentToPreview(
|
||||
query: String = "",
|
||||
active: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -82,13 +82,6 @@ enum class FeatureFlags(
|
|||
defaultValue = true,
|
||||
isFinished = false,
|
||||
),
|
||||
RoomModeration(
|
||||
key = "feature.roomModeration",
|
||||
title = "Room moderation",
|
||||
description = "Add moderation features to the room for users with permissions",
|
||||
defaultValue = true,
|
||||
isFinished = false,
|
||||
),
|
||||
RoomDirectorySearch(
|
||||
key = "feature.roomdirectorysearch",
|
||||
title = "Room directory search",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ class StaticFeatureFlagProvider @Inject constructor() :
|
|||
FeatureFlags.Mentions -> true
|
||||
FeatureFlags.MarkAsUnread -> true
|
||||
FeatureFlags.RoomListFilters -> true
|
||||
FeatureFlags.RoomModeration -> false
|
||||
FeatureFlags.RoomDirectorySearch -> false
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -96,4 +96,7 @@ interface MatrixClient : Closeable {
|
|||
fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<MatrixRoomInfo>>
|
||||
|
||||
fun isMe(userId: UserId?) = userId == sessionId
|
||||
|
||||
suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit>
|
||||
suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,13 +35,8 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
|||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
|
||||
|
|
@ -87,6 +82,12 @@ interface MatrixRoom : Closeable {
|
|||
*/
|
||||
suspend fun updateMembers()
|
||||
|
||||
/**
|
||||
* Get the members of the room. Note: generally this should not be used, please use
|
||||
* [membersStateFlow] and [updateMembers] instead.
|
||||
*/
|
||||
suspend fun getMembers(limit: Int = 5): Result<List<RoomMember>>
|
||||
|
||||
/**
|
||||
* Will return an updated member or an error.
|
||||
*/
|
||||
|
|
@ -183,18 +184,6 @@ interface MatrixRoom : Closeable {
|
|||
suspend fun canUserJoinCall(userId: UserId): Result<Boolean> =
|
||||
canUserSendState(userId, StateEventType.CALL_MEMBER)
|
||||
|
||||
fun usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
|
||||
return roomInfoFlow
|
||||
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
|
||||
.distinctUntilChanged()
|
||||
.combine(membersStateFlow) { powerLevels, membersState ->
|
||||
membersState.roomMembers()
|
||||
.orEmpty()
|
||||
.filter { powerLevels.containsKey(it.userId) }
|
||||
.toPersistentList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
||||
|
||||
suspend fun removeAvatar(): Result<Unit>
|
||||
|
|
|
|||
|
|
@ -35,3 +35,7 @@ fun MatrixRoomMembersState.roomMembers(): List<RoomMember>? {
|
|||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun MatrixRoomMembersState.joinedRoomMembers(): List<RoomMember> {
|
||||
return roomMembers().orEmpty().filter { it.membership == RoomMembershipState.JOIN }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.libraries.matrix.api.room
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
data class RoomMember(
|
||||
val userId: UserId,
|
||||
|
|
@ -78,3 +79,9 @@ enum class RoomMembershipState {
|
|||
fun RoomMember.getBestName(): String {
|
||||
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
|
||||
}
|
||||
|
||||
fun RoomMember.toMatrixUser() = MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.matrix.api.room.powerlevels
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Return a flow of the list of room members who are still in the room (with membership == RoomMembershipState.JOIN)
|
||||
* and who have the given role.
|
||||
*/
|
||||
fun MatrixRoom.usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
|
||||
return roomInfoFlow
|
||||
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
|
||||
.combine(membersStateFlow) { powerLevels, membersState ->
|
||||
membersState.joinedRoomMembers()
|
||||
.filter { powerLevels.containsKey(it.userId) }
|
||||
.toPersistentList()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ suspend fun MatrixRoom.canInvite(): Result<Boolean> = canUserInvite(sessionId)
|
|||
suspend fun MatrixRoom.canKick(): Result<Boolean> = canUserKick(sessionId)
|
||||
|
||||
/**
|
||||
* Shortcut for calling [MatrixRoom.canBanUser] with our own user.
|
||||
* Shortcut for calling [MatrixRoom.canUserBan] with our own user.
|
||||
*/
|
||||
suspend fun MatrixRoom.canBan(): Result<Boolean> = canUserBan(sessionId)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.matrix.api.room.recent
|
||||
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
private const val MAX_RECENT_DIRECT_ROOMS_TO_RETURN = 5
|
||||
|
||||
data class RecentDirectRoom(
|
||||
val roomId: RoomId,
|
||||
val matrixUser: MatrixUser,
|
||||
)
|
||||
|
||||
suspend fun MatrixClient.getRecentDirectRooms(
|
||||
maxNumberOfResults: Int = MAX_RECENT_DIRECT_ROOMS_TO_RETURN,
|
||||
): List<RecentDirectRoom> {
|
||||
val result = mutableListOf<RecentDirectRoom>()
|
||||
val foundUserIds = mutableSetOf<UserId>()
|
||||
getRecentlyVisitedRooms().getOrNull()?.let { roomIds ->
|
||||
roomIds
|
||||
.mapNotNull { roomId -> getRoom(roomId) }
|
||||
.filter { it.isDm && it.isJoined() }
|
||||
.map { room ->
|
||||
val otherUser = room.getMembers().getOrNull()
|
||||
?.firstOrNull { it.userId != sessionId }
|
||||
?.takeIf { foundUserIds.add(it.userId) }
|
||||
?.toMatrixUser()
|
||||
if (otherUser != null) {
|
||||
result.add(
|
||||
RecentDirectRoom(room.roomId, otherUser)
|
||||
)
|
||||
// Return early to avoid useless computation
|
||||
if (result.size >= maxNumberOfResults) {
|
||||
return@map
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun MatrixRoom.isJoined(): Boolean {
|
||||
return roomInfoFlow.first().currentUserMembership == CurrentUserMembership.JOINED
|
||||
}
|
||||
|
|
@ -21,6 +21,17 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface SessionVerificationService {
|
||||
/**
|
||||
* This flow stores the local verification status of the current session.
|
||||
*
|
||||
* We should ideally base the verified status in the Rust SDK info, but there are several issues with that approach:
|
||||
*
|
||||
* - The SDK takes a while to report this value, resulting in a delay of 1-2s in displaying the UI.
|
||||
* - We need to add a 'Skip' option for testing purposes, which would not be possible if we relied only on the SDK.
|
||||
* - The SDK sometimes doesn't report the verification state if there is no network connection when the app boots.
|
||||
*/
|
||||
val needsVerificationFlow: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* State of the current verification flow ([VerificationFlowState.Initial] if not started).
|
||||
*/
|
||||
|
|
@ -72,6 +83,11 @@ interface SessionVerificationService {
|
|||
* Returns the verification service state to the initial step.
|
||||
*/
|
||||
suspend fun reset()
|
||||
|
||||
/**
|
||||
* Saves the current session state as [verified].
|
||||
*/
|
||||
suspend fun saveVerifiedState(verified: Boolean)
|
||||
}
|
||||
|
||||
/** Verification status of the current session. */
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ class RustMatrixClient(
|
|||
syncService = rustSyncService,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
dispatchers = dispatchers,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
|
||||
private val roomDirectoryService = RustRoomDirectoryService(
|
||||
|
|
@ -183,6 +184,7 @@ class RustMatrixClient(
|
|||
isTokenValid = false,
|
||||
loginType = existingData.loginType,
|
||||
passphrase = existingData.passphrase,
|
||||
needsVerification = existingData.needsVerification,
|
||||
)
|
||||
sessionStore.updateData(newData)
|
||||
Timber.d("Removed session data with token: '...$anonymizedToken'.")
|
||||
|
|
@ -210,6 +212,7 @@ class RustMatrixClient(
|
|||
isTokenValid = true,
|
||||
loginType = existingData.loginType,
|
||||
passphrase = existingData.passphrase,
|
||||
needsVerification = existingData.needsVerification,
|
||||
)
|
||||
sessionStore.updateData(newData)
|
||||
Timber.d("Saved new session data with token: '...$anonymizedToken'.")
|
||||
|
|
@ -235,6 +238,7 @@ class RustMatrixClient(
|
|||
client = client,
|
||||
isSyncServiceReady = rustSyncService.syncState.map { it == SyncState.Running },
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
|
||||
private val eventFilters = TimelineConfig.excludedEvents
|
||||
|
|
@ -444,6 +448,18 @@ class RustMatrixClient(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit> = withContext(sessionDispatcher) {
|
||||
runCatching {
|
||||
client.trackRecentlyVisitedRoom(roomId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> = withContext(sessionDispatcher) {
|
||||
runCatching {
|
||||
client.getRecentlyVisitedRooms().map(::RoomId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncService(): SyncService = rustSyncService
|
||||
|
||||
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
isTokenValid = true,
|
||||
loginType = LoginType.PASSWORD,
|
||||
passphrase = pendingPassphrase,
|
||||
needsVerification = true,
|
||||
)
|
||||
}
|
||||
sessionStore.storeData(sessionData)
|
||||
|
|
@ -195,7 +196,8 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
it.session().toSessionData(
|
||||
isTokenValid = true,
|
||||
loginType = LoginType.OIDC,
|
||||
passphrase = pendingPassphrase
|
||||
passphrase = pendingPassphrase,
|
||||
needsVerification = true,
|
||||
)
|
||||
}
|
||||
pendingOidcAuthenticationData?.close()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
|||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
|
|
@ -48,10 +49,11 @@ import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecover
|
|||
import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException
|
||||
|
||||
internal class RustEncryptionService(
|
||||
client: Client,
|
||||
private val client: Client,
|
||||
syncService: RustSyncService,
|
||||
sessionCoroutineScope: CoroutineScope,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val sessionStore: SessionStore,
|
||||
) : EncryptionService {
|
||||
private val service: Encryption = client.encryption()
|
||||
|
||||
|
|
@ -186,6 +188,9 @@ internal class RustEncryptionService(
|
|||
override suspend fun recover(recoveryKey: String): Result<Unit> = withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
service.recover(recoveryKey)
|
||||
val existingSession = sessionStore.getSession(client.userId())
|
||||
?: error("Failed to save verification state. No session with id ${client.userId()}")
|
||||
sessionStore.updateData(existingSession.copy(needsVerification = false))
|
||||
}.mapFailure {
|
||||
it.mapRecoveryException()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ internal fun Session.toSessionData(
|
|||
isTokenValid: Boolean,
|
||||
loginType: LoginType,
|
||||
passphrase: String?,
|
||||
needsVerification: Boolean,
|
||||
) = SessionData(
|
||||
userId = userId,
|
||||
deviceId = deviceId,
|
||||
|
|
@ -37,4 +38,5 @@ internal fun Session.toSessionData(
|
|||
isTokenValid = isTokenValid,
|
||||
loginType = loginType,
|
||||
passphrase = passphrase,
|
||||
needsVerification = needsVerification,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,8 +71,13 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||
|
|
@ -164,7 +169,11 @@ class RustMatrixRoom(
|
|||
override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
timeline.membershipChangeEventReceived
|
||||
val powerLevelChanges = roomInfoFlow.map { it.userPowerLevels }.distinctUntilChanged()
|
||||
val membershipChanges = timeline.membershipChangeEventReceived.onStart { emit(Unit) }
|
||||
combine(membershipChanges, powerLevelChanges) { _, _ -> }
|
||||
// Skip initial one
|
||||
.drop(1)
|
||||
// The new events should already be in the SDK cache, no need to fetch them from the server
|
||||
.onEach { roomMemberListFetcher.fetchRoomMembers(source = RoomMemberListFetcher.Source.CACHE) }
|
||||
.launchIn(roomCoroutineScope)
|
||||
|
|
@ -228,6 +237,16 @@ class RustMatrixRoom(
|
|||
roomMemberListFetcher.fetchRoomMembers(source = source)
|
||||
}
|
||||
|
||||
override suspend fun getMembers(limit: Int) = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerRoom.members().use {
|
||||
it.nextChunk(limit.toUInt()).orEmpty().map { roomMember ->
|
||||
RoomMemberMapper.map(roomMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getUpdatedMember(userId: UserId): Result<RoomMember> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
RoomMemberMapper.map(innerRoom.member(userId.value))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package io.element.android.libraries.matrix.impl.verification
|
||||
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
|
|
@ -23,108 +24,97 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
|
|||
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.Encryption
|
||||
import org.matrix.rustcomponents.sdk.RecoveryState
|
||||
import org.matrix.rustcomponents.sdk.RecoveryStateListener
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationController
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.VerificationState
|
||||
import org.matrix.rustcomponents.sdk.VerificationStateListener
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData
|
||||
|
||||
class RustSessionVerificationService(
|
||||
client: Client,
|
||||
private val client: Client,
|
||||
isSyncServiceReady: Flow<Boolean>,
|
||||
sessionCoroutineScope: CoroutineScope,
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val sessionStore: SessionStore,
|
||||
) : SessionVerificationService, SessionVerificationControllerDelegate {
|
||||
private var verificationStateListenerTaskHandle: TaskHandle? = null
|
||||
private var recoveryStateListenerTaskHandle: TaskHandle? = null
|
||||
private val encryptionService: Encryption = client.encryption()
|
||||
private lateinit var verificationController: SessionVerificationController
|
||||
|
||||
// Listen for changes in verification status and update accordingly
|
||||
private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
|
||||
override fun onUpdate(status: VerificationState) {
|
||||
Timber.d("New verification state: $status")
|
||||
updateVerificationStatus(status)
|
||||
}
|
||||
})
|
||||
|
||||
// In case we enter the recovery key instead we check changes in the recovery state, since the listener above won't be triggered
|
||||
private val recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener {
|
||||
override fun onUpdate(status: RecoveryState) {
|
||||
Timber.d("New recovery state: $status")
|
||||
// We could check the `RecoveryState`, but it's easier to just use the verification state directly
|
||||
updateVerificationStatus(encryptionService.verificationState())
|
||||
}
|
||||
})
|
||||
|
||||
override val needsVerificationFlow: StateFlow<Boolean> = sessionStore.sessionsFlow()
|
||||
.map { sessions -> sessions.firstOrNull { it.userId == client.userId() }?.needsVerification.orFalse() }
|
||||
.distinctUntilChanged()
|
||||
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
|
||||
|
||||
private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
|
||||
override val verificationFlowState = _verificationFlowState.asStateFlow()
|
||||
|
||||
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
|
||||
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
|
||||
|
||||
override val isReady = MutableStateFlow(false)
|
||||
override val isReady = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
|
||||
|
||||
override val canVerifySessionFlow = combine(sessionVerifiedStatus, isReady) { verificationStatus, isReady ->
|
||||
isReady && verificationStatus == SessionVerifiedStatus.NotVerified
|
||||
}
|
||||
|
||||
init {
|
||||
isSyncServiceReady
|
||||
.onEach { syncServiceReady ->
|
||||
if (syncServiceReady) {
|
||||
isReady.value = true
|
||||
runCatching {
|
||||
// If the controller was failed to initialize before, we try to get it again
|
||||
if (!this::verificationController.isInitialized) {
|
||||
verificationController = client.getSessionVerificationController()
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
isReady.value = false
|
||||
Timber.e(it, "Failed to get verification controller. Trying again in next sync.")
|
||||
}
|
||||
} else {
|
||||
isReady.value = false
|
||||
}
|
||||
}
|
||||
.launchIn(sessionCoroutineScope)
|
||||
|
||||
isReady.onEach { isReady ->
|
||||
if (isReady) {
|
||||
Timber.d("Starting verification service")
|
||||
// Setup delegate
|
||||
verificationController.setDelegate(this)
|
||||
|
||||
// Immediate status update
|
||||
updateVerificationStatus(encryptionService.verificationState())
|
||||
|
||||
// Listen for changes in verification status and update accordingly
|
||||
verificationStateListenerTaskHandle?.cancelAndDestroy()
|
||||
verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
|
||||
override fun onUpdate(status: VerificationState) {
|
||||
Timber.d("New verification state: $status")
|
||||
updateVerificationStatus(status)
|
||||
}
|
||||
})
|
||||
|
||||
// In case we enter the recovery key instead we check changes in the recovery state, since the listener above won't be triggered
|
||||
recoveryStateListenerTaskHandle?.cancelAndDestroy()
|
||||
recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener {
|
||||
override fun onUpdate(status: RecoveryState) {
|
||||
Timber.d("New recovery state: $status")
|
||||
// We could check the `RecoveryState`, but it's easier to just use the verification state directly
|
||||
updateVerificationStatus(encryptionService.verificationState())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Timber.d("Stopping verification service")
|
||||
if (this::verificationController.isInitialized) {
|
||||
verificationController.setDelegate(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
|
||||
override suspend fun requestVerification() = tryOrFail {
|
||||
if (!this::verificationController.isInitialized) {
|
||||
verificationController = client.getSessionVerificationController()
|
||||
verificationController.setDelegate(this)
|
||||
}
|
||||
verificationController.requestVerification()
|
||||
}
|
||||
|
||||
|
|
@ -164,9 +154,26 @@ class RustSessionVerificationService(
|
|||
}
|
||||
|
||||
override fun didFinish() {
|
||||
_verificationFlowState.value = VerificationFlowState.Finished
|
||||
// Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false
|
||||
updateVerificationStatus(VerificationState.VERIFIED)
|
||||
sessionCoroutineScope.launch {
|
||||
// Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it returns false if run immediately
|
||||
// It also sometimes unexpectedly fails to report the session as verified, so we have to handle that possibility and fail if needed
|
||||
runCatching {
|
||||
withTimeout(30.seconds) {
|
||||
while (!verificationController.isVerified()) {
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
saveVerifiedState(true)
|
||||
updateVerificationStatus(VerificationState.VERIFIED)
|
||||
_verificationFlowState.value = VerificationFlowState.Finished
|
||||
}
|
||||
.onFailure {
|
||||
Timber.e(it, "Verification finished, but the Rust SDK still reports the session as unverified.")
|
||||
didFail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun didReceiveVerificationData(data: RustSessionVerificationData) {
|
||||
|
|
@ -188,12 +195,21 @@ class RustSessionVerificationService(
|
|||
_verificationFlowState.value = VerificationFlowState.Initial
|
||||
}
|
||||
|
||||
override suspend fun saveVerifiedState(verified: Boolean) = tryOrFail {
|
||||
val existingSession = sessionStore.getSession(client.userId())
|
||||
?: error("Failed to save verification state. No session with id ${client.userId()}")
|
||||
sessionStore.updateData(existingSession.copy(needsVerification = !verified))
|
||||
// Wait until the new state is saved
|
||||
needsVerificationFlow.first { needsVerification -> !needsVerification }
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
Timber.d("Destroying RustSessionVerificationService")
|
||||
recoveryStateListenerTaskHandle?.cancelAndDestroy()
|
||||
verificationStateListenerTaskHandle.cancelAndDestroy()
|
||||
recoveryStateListenerTaskHandle.cancelAndDestroy()
|
||||
if (this::verificationController.isInitialized) {
|
||||
verificationController.setDelegate(null)
|
||||
(verificationController as? SessionVerificationController)?.destroy()
|
||||
verificationController.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -255,4 +255,16 @@ class FakeMatrixClient(
|
|||
fun givenRemoveAvatarResult(result: Result<Unit>) {
|
||||
removeAvatarResult = result
|
||||
}
|
||||
|
||||
private val visitedRoomsId: MutableList<RoomId> = mutableListOf()
|
||||
|
||||
override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit> {
|
||||
visitedRoomsId.removeAll { it == roomId }
|
||||
visitedRoomsId.add(0, roomId)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> {
|
||||
return Result.success(visitedRoomsId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,10 @@ class FakeMatrixRoom(
|
|||
return getRoomMemberResult
|
||||
}
|
||||
|
||||
override suspend fun getMembers(limit: Int): Result<List<RoomMember>> {
|
||||
return Result.success(emptyList())
|
||||
}
|
||||
|
||||
override suspend fun updateRoomNotificationSettings(): Result<Unit> = simulateLongTask {
|
||||
val notificationSettings = notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
|
||||
roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(notificationSettings)
|
||||
|
|
|
|||
|
|
@ -20,17 +20,22 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
|
|||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class FakeSessionVerificationService : SessionVerificationService {
|
||||
class FakeSessionVerificationService(
|
||||
var saveVerifiedStateResult: LambdaOneParamRecorder<Boolean, Unit> = lambdaRecorder<Boolean, Unit> {}
|
||||
) : SessionVerificationService {
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
|
||||
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
|
||||
private var _canVerifySessionFlow = MutableStateFlow(true)
|
||||
var shouldFail = false
|
||||
|
||||
override val needsVerificationFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
|
||||
override val verificationFlowState: StateFlow<VerificationFlowState> = _verificationFlowState
|
||||
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
|
||||
override val canVerifySessionFlow: Flow<Boolean> = _canVerifySessionFlow
|
||||
|
|
@ -89,7 +94,15 @@ class FakeSessionVerificationService : SessionVerificationService {
|
|||
_isReady.value = value
|
||||
}
|
||||
|
||||
fun givenNeedsVerification(value: Boolean) {
|
||||
needsVerificationFlow.value = value
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
_verificationFlowState.value = VerificationFlowState.Initial
|
||||
}
|
||||
|
||||
override suspend fun saveVerifiedState(verified: Boolean) {
|
||||
saveVerifiedStateResult(verified)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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.push.impl.permission
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
// TODO EAx move
|
||||
class NotificationPermissionManager @Inject constructor(
|
||||
private val sdkIntProvider: BuildVersionSdkIntProvider,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
fun isPermissionGranted(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/*
|
||||
fun eventuallyRequestPermission(
|
||||
activity: Activity,
|
||||
requestPermissionLauncher: ActivityResultLauncher<Array<String>>,
|
||||
showRationale: Boolean = true,
|
||||
ignorePreference: Boolean = false,
|
||||
) {
|
||||
if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
|
||||
// if (!vectorPreferences.areNotificationEnabledForDevice() && !ignorePreference) return
|
||||
checkPermissions(
|
||||
listOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
activity,
|
||||
activityResultLauncher = requestPermissionLauncher,
|
||||
if (showRationale) R.string.permissions_rationale_msg_notification else 0
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
fun eventuallyRevokePermission(
|
||||
activity: Activity,
|
||||
) {
|
||||
if (!sdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) return
|
||||
activity.revokeSelfPermissionOnKill(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,7 @@ fun RoomSelectView(
|
|||
.consumeWindowInsets(paddingValues)
|
||||
) {
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeHolderTitle = stringResource(CommonStrings.action_search),
|
||||
query = state.query,
|
||||
onQueryChange = { state.eventSink(RoomSelectEvents.UpdateQuery(it)) },
|
||||
|
|
|
|||
|
|
@ -18,16 +18,32 @@ package io.element.android.libraries.sessionstorage.api
|
|||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Data class representing the session data to store locally.
|
||||
*/
|
||||
data class SessionData(
|
||||
/** The user ID of the logged in user. */
|
||||
val userId: String,
|
||||
/** The device ID of the session. */
|
||||
val deviceId: String,
|
||||
/** The current access token of the session. */
|
||||
val accessToken: String,
|
||||
/** The optional current refresh token of the session. */
|
||||
val refreshToken: String?,
|
||||
/** The homeserver URL of the session. */
|
||||
val homeserverUrl: String,
|
||||
/** The Open ID Connect info for this session, if any. */
|
||||
val oidcData: String?,
|
||||
/** The Sliding Sync Proxy URL for this session, if any. */
|
||||
val slidingSyncProxy: String?,
|
||||
/** The timestamp of the last login. May be `null` in very old sessions. */
|
||||
val loginTimestamp: Date?,
|
||||
/** Whether the [accessToken] is valid or not. */
|
||||
val isTokenValid: Boolean,
|
||||
/** The login type used to authenticate the session. */
|
||||
val loginType: LoginType,
|
||||
/** The optional passphrase used to encrypt data in the SDK local store. */
|
||||
val passphrase: String?,
|
||||
/** Whether the session needs verification. */
|
||||
val needsVerification: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ internal fun SessionData.toDbModel(): DbSessionData {
|
|||
isTokenValid = if (isTokenValid) 1L else 0L,
|
||||
loginType = loginType.name,
|
||||
passphrase = passphrase,
|
||||
needsVerification = if (needsVerification) 1L else 0L,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -50,5 +51,6 @@ internal fun DbSessionData.toApiModel(): SessionData {
|
|||
isTokenValid = isTokenValid == 1L,
|
||||
loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name),
|
||||
passphrase = passphrase,
|
||||
needsVerification = needsVerification == 1L,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -23,7 +23,9 @@ CREATE TABLE SessionData (
|
|||
isTokenValid INTEGER NOT NULL DEFAULT 1,
|
||||
loginType TEXT,
|
||||
-- added in version 5
|
||||
passphrase TEXT
|
||||
passphrase TEXT,
|
||||
-- added in version 6
|
||||
needsVerification INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
-- Migrate DB from version 5
|
||||
-- For users migrating previously logged in sessions, we force them to verify them too
|
||||
|
||||
ALTER TABLE SessionData ADD COLUMN needsVerification INTEGER NOT NULL DEFAULT 1;
|
||||
|
|
@ -144,6 +144,7 @@ class DatabaseSessionStoreTests {
|
|||
isTokenValid = 1,
|
||||
loginType = null,
|
||||
passphrase = "aPassphrase",
|
||||
needsVerification = 1L,
|
||||
)
|
||||
val secondSessionData = SessionData(
|
||||
userId = "userId",
|
||||
|
|
@ -157,6 +158,7 @@ class DatabaseSessionStoreTests {
|
|||
isTokenValid = 1,
|
||||
loginType = null,
|
||||
passphrase = "aPassphraseAltered",
|
||||
needsVerification = 0L,
|
||||
)
|
||||
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
|
||||
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
|
||||
|
|
@ -177,6 +179,7 @@ class DatabaseSessionStoreTests {
|
|||
assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
|
||||
assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData)
|
||||
assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase)
|
||||
assertThat(alteredSession.needsVerification).isEqualTo(secondSessionData.needsVerification)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -193,6 +196,7 @@ class DatabaseSessionStoreTests {
|
|||
isTokenValid = 1,
|
||||
loginType = null,
|
||||
passphrase = "aPassphrase",
|
||||
needsVerification = 1L,
|
||||
)
|
||||
val secondSessionData = SessionData(
|
||||
userId = "userIdUnknown",
|
||||
|
|
@ -206,6 +210,7 @@ class DatabaseSessionStoreTests {
|
|||
isTokenValid = 1,
|
||||
loginType = null,
|
||||
passphrase = "aPassphraseAltered",
|
||||
needsVerification = 0L,
|
||||
)
|
||||
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
|
||||
|
||||
|
|
@ -224,5 +229,6 @@ class DatabaseSessionStoreTests {
|
|||
assertThat(notAlteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
|
||||
assertThat(notAlteredSession.oidcData).isEqualTo(firstSessionData.oidcData)
|
||||
assertThat(notAlteredSession.passphrase).isEqualTo(firstSessionData.passphrase)
|
||||
assertThat(notAlteredSession.needsVerification).isEqualTo(firstSessionData.needsVerification)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,4 +31,5 @@ internal fun aSessionData() = SessionData(
|
|||
isTokenValid = 1,
|
||||
loginType = LoginType.UNKNOWN.name,
|
||||
passphrase = null,
|
||||
needsVerification = 0L,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@
|
|||
<string name="common_server_not_supported">"Сервер не падтрымліваецца"</string>
|
||||
<string name="common_server_url">"URL-адрас сервера"</string>
|
||||
<string name="common_settings">"Налады"</string>
|
||||
<string name="common_shared_location">"Абагуленыя геаданыя"</string>
|
||||
<string name="common_shared_location">"Абагуленае месцазнаходжанне"</string>
|
||||
<string name="common_signing_out">"Выхад"</string>
|
||||
<string name="common_starting_chat">"Пачатак чата…"</string>
|
||||
<string name="common_sticker">"Стыкер"</string>
|
||||
|
|
@ -249,12 +249,6 @@
|
|||
<string name="invite_friends_text">"Гэй, пагавары са мной у %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Паведаміць аб памылцы з дапамогай Rageshake"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Пацвердзіце гэтую прыладу, каб наладзіць бяспечны абмен паведамленнямі."</string>
|
||||
<string name="screen_identity_confirmation_title">"Пацвердзіце, што гэта вы"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Цяпер вы можаце бяспечна чытаць і адпраўляць паведамленні, і ўсе, з кім вы маеце зносіны ў чаце, таксама могуць давяраць гэтай прыладзе."</string>
|
||||
<string name="screen_identity_confirmed_title">"Прылада праверана"</string>
|
||||
<string name="screen_identity_use_another_device">"Выкарыстоўвайце іншую прыладу"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Чаканне на іншай прыладзе…"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Не ўдалося выбраць носьбіт, паўтарыце спробу."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>
|
||||
|
|
@ -263,6 +257,7 @@
|
|||
<string name="screen_room_member_details_block_alert_action">"Заблакіраваць"</string>
|
||||
<string name="screen_room_member_details_block_alert_description">"Заблакіраваныя карыстальнікі не змогуць адпраўляць вам паведамленні, і ўсе іх паведамленні будуць схаваны. Вы можаце разблакіраваць іх у любы час."</string>
|
||||
<string name="screen_room_member_details_block_user">"Заблакіраваць карыстальніка"</string>
|
||||
<string name="screen_room_member_details_title">"Профіль"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_action">"Разблакіраваць"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_description">"Вы зноў зможаце ўбачыць усе паведамленні."</string>
|
||||
<string name="screen_room_member_details_unblock_user">"Разблакіраваць карыстальніка"</string>
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@
|
|||
<string name="common_privacy_policy">"Zásady ochrany osobních údajů"</string>
|
||||
<string name="common_reaction">"Reakce"</string>
|
||||
<string name="common_reactions">"Reakce"</string>
|
||||
<string name="common_recovery_key">"Klíč pro obnovu"</string>
|
||||
<string name="common_recovery_key">"Klíč pro obnovení"</string>
|
||||
<string name="common_refreshing">"Obnovování…"</string>
|
||||
<string name="common_replying_to">"Odpověď na %1$s"</string>
|
||||
<string name="common_report_a_bug">"Nahlásit chybu"</string>
|
||||
|
|
@ -249,12 +249,6 @@
|
|||
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Ověřte toto zařízení a nastavte zabezpečené zasílání zpráv."</string>
|
||||
<string name="screen_identity_confirmation_title">"Potvrďte, že jste to vy"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Nyní můžete bezpečně číst nebo odesílat zprávy, a kdokoli, s kým chatujete, může tomuto zařízení důvěřovat."</string>
|
||||
<string name="screen_identity_confirmed_title">"Zařízení ověřeno"</string>
|
||||
<string name="screen_identity_use_another_device">"Použít jiné zařízení"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Čekání na jiném zařízení…"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Výběr média se nezdařil, zkuste to prosím znovu."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
|
||||
|
|
@ -263,6 +257,7 @@
|
|||
<string name="screen_room_member_details_block_alert_action">"Zablokovat"</string>
|
||||
<string name="screen_room_member_details_block_alert_description">"Blokovaní uživatelé vám nebudou moci posílat zprávy a všechny jejich zprávy budou skryty. Můžete je kdykoli odblokovat."</string>
|
||||
<string name="screen_room_member_details_block_user">"Zablokovat uživatele"</string>
|
||||
<string name="screen_room_member_details_title">"Profil"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_action">"Odblokovat"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_description">"Znovu uvidíte všechny zprávy od nich."</string>
|
||||
<string name="screen_room_member_details_unblock_user">"Odblokovat uživatele"</string>
|
||||
|
|
|
|||
|
|
@ -175,7 +175,9 @@
|
|||
<string name="common_privacy_policy">"Datenschutzerklärung"</string>
|
||||
<string name="common_reaction">"Reaktion"</string>
|
||||
<string name="common_reactions">"Reaktionen"</string>
|
||||
<string name="common_recovery_key">"Wiederherstellungsschlüssel"</string>
|
||||
<string name="common_recovery_key">
|
||||
<b>"Wiederherstellungsschlüssel"</b>
|
||||
</string>
|
||||
<string name="common_refreshing">"Wird erneuert…"</string>
|
||||
<string name="common_replying_to">"%1$s antworten"</string>
|
||||
<string name="common_report_a_bug">"Einen Fehler melden"</string>
|
||||
|
|
@ -245,12 +247,6 @@
|
|||
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Schüttel heftig zum Melden von Fehlern"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Verifiziere dieses Gerät, um sicheres Messaging einzurichten."</string>
|
||||
<string name="screen_identity_confirmation_title">"Bestätige, dass du es bist"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Du kannst nun verschlüsselte Nachrichten lesen oder versenden."</string>
|
||||
<string name="screen_identity_confirmed_title">"Gerät verifiziert"</string>
|
||||
<string name="screen_identity_use_another_device">"Ein anderes Gerät verwenden"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Bitte warten bis das andere Gerät bereit ist."</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut."</string>
|
||||
|
|
@ -259,6 +255,7 @@
|
|||
<string name="screen_room_member_details_block_alert_action">"Blockieren"</string>
|
||||
<string name="screen_room_member_details_block_alert_description">"Blockierte Benutzer können Dir keine Nachrichten senden und alle ihre alten Nachrichten werden ausgeblendet. Die Blockierung kann jederzeit aufgehoben werden."</string>
|
||||
<string name="screen_room_member_details_block_user">"Benutzer blockieren"</string>
|
||||
<string name="screen_room_member_details_title">"Profil"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_action">"Blockierung aufheben"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_description">"Der Nutzer kann dir wieder Nachrichten senden & alle Nachrichten des Nutzers werden wieder angezeigt."</string>
|
||||
<string name="screen_room_member_details_unblock_user">"Blockierung aufheben"</string>
|
||||
|
|
|
|||
|
|
@ -245,12 +245,6 @@
|
|||
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Rageshake pour signaler un problème"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Vérifier cette session pour configurer votre messagerie sécurisée."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirmez votre identité"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Vous pouvez désormais lire ou envoyer des messages en toute sécurité, et toute personne avec qui vous discutez peut également faire confiance à cette session."</string>
|
||||
<string name="screen_identity_confirmed_title">"Session vérifiée"</string>
|
||||
<string name="screen_identity_use_another_device">"Utiliser une autre session"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"En attente d’une autre session…"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Échec de la sélection du média, veuillez réessayer."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Échec du traitement des médias à télécharger, veuillez réessayer."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Échec du téléchargement du média, veuillez réessayer."</string>
|
||||
|
|
|
|||
|
|
@ -245,12 +245,6 @@
|
|||
<string name="invite_friends_text">"Beszélgessünk itt: %1$s, %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Az eszköz rázása a hibajelentéshez"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"A biztonságos üzenetkezelés beállításához ellenőrizze ezt az eszközt."</string>
|
||||
<string name="screen_identity_confirmation_title">"Erősítse meg, hogy Ön az"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Mostantól biztonságosan olvashat vagy küldhet üzeneteket, és bármelyik csevegőpartnere megbízhat ebben az eszközben."</string>
|
||||
<string name="screen_identity_confirmed_title">"Eszköz ellenőrizve"</string>
|
||||
<string name="screen_identity_use_another_device">"Másik eszköz használata"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Várakozás a másik eszközre…"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Nem sikerült kiválasztani a médiát, próbálja újra."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Nem sikerült a média feltöltése, próbálja újra."</string>
|
||||
|
|
@ -259,6 +253,7 @@
|
|||
<string name="screen_room_member_details_block_alert_action">"Letiltás"</string>
|
||||
<string name="screen_room_member_details_block_alert_description">"A letiltott felhasználók nem fognak tudni üzeneteket küldeni, és az összes üzenetük rejtve lesz. Bármikor feloldhatja a letiltásukat."</string>
|
||||
<string name="screen_room_member_details_block_user">"Felhasználó letiltása"</string>
|
||||
<string name="screen_room_member_details_title">"Profil"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_action">"Letiltás feloldása"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_description">"Újra láthatja az összes üzenetét."</string>
|
||||
<string name="screen_room_member_details_unblock_user">"Felhasználó kitiltásának feloldása"</string>
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@
|
|||
<string name="common_server_not_supported">"Server tidak didukung"</string>
|
||||
<string name="common_server_url">"URL Server"</string>
|
||||
<string name="common_settings">"Pengaturan"</string>
|
||||
<string name="common_shared_location">"Membagikan lokasi"</string>
|
||||
<string name="common_shared_location">"Lokasi terbagi"</string>
|
||||
<string name="common_signing_out">"Mengeluarkan dari akun"</string>
|
||||
<string name="common_starting_chat">"Memulai obrolan…"</string>
|
||||
<string name="common_sticker">"Stiker"</string>
|
||||
|
|
@ -241,12 +241,6 @@
|
|||
<string name="invite_friends_text">"Hai, bicaralah dengan saya di %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Rageshake untuk melaporkan kutu"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Verifikasi perangkat ini untuk menyiapkan perpesanan aman."</string>
|
||||
<string name="screen_identity_confirmation_title">"Konfirmasi bahwa ini Anda"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Sekarang Anda dapat membaca atau mengirim pesan dengan aman, dan siapa pun yang mengobrol dengan Anda juga dapat mempercayai perangkat ini."</string>
|
||||
<string name="screen_identity_confirmed_title">"Perangkat terverifikasi"</string>
|
||||
<string name="screen_identity_use_another_device">"Gunakan perangkat lain"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Menunggu di perangkat lain…"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Gagal memilih media, silakan coba lagi."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Gagal memproses media untuk diunggah, silakan coba lagi."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Gagal mengunggah media, silakan coba lagi."</string>
|
||||
|
|
@ -255,6 +249,7 @@
|
|||
<string name="screen_room_member_details_block_alert_action">"Blokir"</string>
|
||||
<string name="screen_room_member_details_block_alert_description">"Pengguna yang diblokir tidak akan dapat mengirim Anda pesan dan semua pesan mereka akan disembunyikan. Anda dapat membuka blokirnya kapan saja."</string>
|
||||
<string name="screen_room_member_details_block_user">"Blokir pengguna"</string>
|
||||
<string name="screen_room_member_details_title">"Profil"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_action">"Buka blokir"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_description">"Anda akan dapat melihat semua pesan dari mereka lagi."</string>
|
||||
<string name="screen_room_member_details_unblock_user">"Buka blokir pengguna"</string>
|
||||
|
|
|
|||
|
|
@ -179,7 +179,9 @@
|
|||
<string name="common_privacy_policy">"Политика конфиденциальности"</string>
|
||||
<string name="common_reaction">"Реакция"</string>
|
||||
<string name="common_reactions">"Реакции"</string>
|
||||
<string name="common_recovery_key">"Ключ восстановления"</string>
|
||||
<string name="common_recovery_key">
|
||||
<b>"Ключ восстановления"</b>
|
||||
</string>
|
||||
<string name="common_refreshing">"Обновление…"</string>
|
||||
<string name="common_replying_to">"Отвечает на %1$s"</string>
|
||||
<string name="common_report_a_bug">"Сообщить об ошибке"</string>
|
||||
|
|
@ -249,12 +251,6 @@
|
|||
<string name="invite_friends_text">"Привет, поговори со мной по %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Встряхните устройство, чтобы сообщить об ошибке"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Подтвердите это устройство, чтобы настроить безопасный обмен сообщениями."</string>
|
||||
<string name="screen_identity_confirmation_title">"Подтвердите, что это вы"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству."</string>
|
||||
<string name="screen_identity_confirmed_title">"Устройство проверено"</string>
|
||||
<string name="screen_identity_use_another_device">"Используйте другое устройство"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Ожидание на другом устройстве…"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Не удалось выбрать носитель, попробуйте еще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>
|
||||
|
|
@ -263,6 +259,7 @@
|
|||
<string name="screen_room_member_details_block_alert_action">"Заблокировать"</string>
|
||||
<string name="screen_room_member_details_block_alert_description">"Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время."</string>
|
||||
<string name="screen_room_member_details_block_user">"Заблокировать пользователя"</string>
|
||||
<string name="screen_room_member_details_title">"Профиль"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_action">"Разблокировать"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_description">"Вы снова сможете увидеть все сообщения."</string>
|
||||
<string name="screen_room_member_details_unblock_user">"Разблокировать пользователя"</string>
|
||||
|
|
|
|||
|
|
@ -248,12 +248,6 @@
|
|||
<string name="invite_friends_text">"Ahoj, porozprávajte sa so mnou na %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Zúrivo potriasť pre nahlásenie chyby"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Ak chcete nastaviť zabezpečené správy, overte toto zariadenie."</string>
|
||||
<string name="screen_identity_confirmation_title">"Potvrďte, že ste to vy"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Teraz môžete bezpečne čítať alebo odosielať správy a tomuto zariadeniu môže dôverovať aj ktokoľvek, s kým konverzujete."</string>
|
||||
<string name="screen_identity_confirmed_title">"Zariadenie overené"</string>
|
||||
<string name="screen_identity_use_another_device">"Použiť iné zariadenie"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Čaká sa na druhom zariadení…"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>
|
||||
|
|
@ -262,6 +256,7 @@
|
|||
<string name="screen_room_member_details_block_alert_action">"Zablokovať"</string>
|
||||
<string name="screen_room_member_details_block_alert_description">"Blokovaní používatelia vám nebudú môcť posielať správy a všetky ich správy budú skryté. Môžete ich kedykoľvek odblokovať."</string>
|
||||
<string name="screen_room_member_details_block_user">"Zablokovať používateľa"</string>
|
||||
<string name="screen_room_member_details_title">"Profil"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_action">"Odblokovať"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_description">"Všetky správy od nich budete môcť opäť vidieť."</string>
|
||||
<string name="screen_room_member_details_unblock_user">"Odblokovať používateľa"</string>
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@
|
|||
<string name="common_editing">"Redigerar"</string>
|
||||
<string name="common_emote">"* %1$s %2$s"</string>
|
||||
<string name="common_encryption_enabled">"Kryptering aktiverad"</string>
|
||||
<string name="common_enter_your_pin">"Ange din PIN-kod"</string>
|
||||
<string name="common_error">"Fel"</string>
|
||||
<string name="common_everyone">"Alla"</string>
|
||||
<string name="common_file">"Fil"</string>
|
||||
|
|
@ -143,6 +144,7 @@
|
|||
<string name="common_rich_text_editor">"Riktextredigerare"</string>
|
||||
<string name="common_room_name">"Rumsnamn"</string>
|
||||
<string name="common_room_name_placeholder">"t.ex. ditt projektnamn"</string>
|
||||
<string name="common_screen_lock">"Skärmlås"</string>
|
||||
<string name="common_search_for_someone">"Sök efter någon"</string>
|
||||
<string name="common_search_results">"Sökresultat"</string>
|
||||
<string name="common_security">"Säkerhet"</string>
|
||||
|
|
@ -164,6 +166,7 @@
|
|||
<string name="common_unable_to_decrypt">"Kan inte avkryptera"</string>
|
||||
<string name="common_unable_to_invite_message">"Inbjudan kunde inte skickas till en eller flera användare."</string>
|
||||
<string name="common_unable_to_invite_title">"Kunde inte skicka inbjudningar"</string>
|
||||
<string name="common_unlock">"Lås upp"</string>
|
||||
<string name="common_unmute">"Avtysta"</string>
|
||||
<string name="common_unsupported_event">"Händelse som inte stöds"</string>
|
||||
<string name="common_username">"Användarnamn"</string>
|
||||
|
|
|
|||
|
|
@ -249,10 +249,6 @@
|
|||
<string name="invite_friends_text">"Привіт, пишіть мені за адресою %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Повідомити про ваду за допомогою Rageshake"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Перевірте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."</string>
|
||||
<string name="screen_identity_confirmation_title">"Підтвердіть, що це ви"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."</string>
|
||||
<string name="screen_identity_confirmed_title">"Пристрій перевірено"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Не вдалося вибрати медіафайл, спробуйте ще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не вдалося завантажити медіафайл, спробуйте ще раз."</string>
|
||||
|
|
|
|||
|
|
@ -227,9 +227,6 @@
|
|||
<string name="error_some_messages_have_not_been_sent">"有些訊息尚未傳送"</string>
|
||||
<string name="invite_friends_text">"嘿,來 %1$s 和我聊天:%2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="screen_identity_confirmed_title">"裝置已認證"</string>
|
||||
<string name="screen_identity_use_another_device">"使用另一個裝置"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"正在等待其他裝置……"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"無法上傳媒體檔案,請稍後再試。"</string>
|
||||
<string name="screen_room_member_details_block_alert_action">"封鎖"</string>
|
||||
<string name="screen_room_member_details_block_user">"封鎖使用者"</string>
|
||||
|
|
|
|||
|
|
@ -245,22 +245,15 @@
|
|||
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Rageshake to report bug"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Verify this device to set up secure messaging."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirm that it\'s you"</string>
|
||||
<string name="screen_identity_confirmed_subtitle">"Now you can read or send messages securely, and anyone you chat with can also trust this device."</string>
|
||||
<string name="screen_identity_confirmed_title">"Device verified"</string>
|
||||
<string name="screen_identity_use_another_device">"Use another device"</string>
|
||||
<string name="screen_identity_waiting_on_other_device">"Waiting on other device…"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
|
||||
<string name="screen_room_directory_search_loading_error">"Failed loading"</string>
|
||||
<string name="screen_room_directory_search_title">"Room directory"</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
||||
<string name="screen_room_member_details_block_alert_action">"Block"</string>
|
||||
<string name="screen_room_member_details_block_alert_description">"Blocked users won\'t be able to send you messages and all their messages will be hidden. You can unblock them anytime."</string>
|
||||
<string name="screen_room_member_details_block_user">"Block user"</string>
|
||||
<string name="screen_room_member_details_title">"Profile"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_action">"Unblock"</string>
|
||||
<string name="screen_room_member_details_unblock_alert_description">"You\'ll be able to see all messages from them again."</string>
|
||||
<string name="screen_room_member_details_unblock_user">"Unblock user"</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue