Merge branch 'develop' into feature/fga/image_loading

This commit is contained in:
ganfra 2023-05-02 15:29:06 +02:00
commit 4b60b14550
182 changed files with 2492 additions and 884 deletions

View file

@ -47,7 +47,9 @@ suspend fun <T> (suspend () -> T).execute(state: MutableState<Async<T>>, errorMa
}
suspend fun <T> (suspend () -> Result<T>).executeResult(state: MutableState<Async<T>>) {
state.value = Async.Loading()
if (state.value !is Async.Success) {
state.value = Async.Loading()
}
this().fold(
onSuccess = {
state.value = Async.Success(it)

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.flow.flow
/** Create a Flow emitting a single error event. It should be useful for tests. */
fun <T> errorFlow(throwable: Throwable) = flow<T> { throw throwable }

View file

@ -41,7 +41,11 @@ import io.element.android.libraries.designsystem.theme.components.Text
import timber.log.Timber
@Composable
fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
fun Avatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val commonModifier = modifier
.size(avatarData.size.dp)
.clip(CircleShape)
@ -54,6 +58,7 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
ImageAvatar(
avatarData = avatarData,
modifier = commonModifier,
contentDescription = contentDescription,
)
}
}
@ -62,13 +67,14 @@ fun Avatar(avatarData: AvatarData, modifier: Modifier = Modifier) {
private fun ImageAvatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
AsyncImage(
model = avatarData,
onError = {
Timber.e("TAG", "Error $it\n${it.result}", it.result.throwable)
},
contentDescription = null,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
placeholder = debugPlaceholderAvatar(),
modifier = modifier
@ -89,11 +95,11 @@ private fun InitialsAvatar(
end = Offset(100f, 0f)
)
Box(
modifier.background(brush = initialsGradient)
modifier.background(brush = initialsGradient),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = avatarData.getInitial(),
text = avatarData.initial,
fontSize = (avatarData.size.dp / 2).value.sp,
color = Color.White,
)

View file

@ -30,8 +30,37 @@ data class AvatarData(
@IgnoredOnParcel
val size: AvatarSize = AvatarSize.MEDIUM
) : Parcelable {
fun getInitial(): String {
val firstChar = name?.firstOrNull() ?: id.getOrNull(1) ?: '?'
return firstChar.uppercase()
@IgnoredOnParcel
val initial by lazy {
(name?.takeIf { it.isNotBlank() } ?: id)
.let { dn ->
var startIndex = 0
val initial = dn[startIndex]
if (initial in listOf('@', '#', '+') && dn.length > 1) {
startIndex++
}
var length = 1
var first = dn[startIndex]
// LEFT-TO-RIGHT MARK
if (dn.length >= 2 && 0x200e == first.code) {
startIndex++
first = dn[startIndex]
}
// check if its the start of a surrogate pair
if (first.code in 0xD800..0xDBFF && dn.length > startIndex + 1) {
val second = dn[startIndex + 1]
if (second.code in 0xDC00..0xDFFF) {
length++
}
}
dn.substring(startIndex, startIndex + length)
}
.uppercase()
}
}

View file

@ -0,0 +1,45 @@
/*
* 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 android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.platform.debugInspectorInfo
/**
* Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise.
*/
@SuppressLint("UnnecessaryComposedModifier") // It's actually necessary due to the `@Composable` lambdas
fun Modifier.applyIf(
condition: Boolean,
ifTrue: @Composable Modifier.() -> Modifier,
ifFalse: @Composable (Modifier.() -> Modifier)? = null
): Modifier =
composed(
inspectorInfo = debugInspectorInfo {
name = "applyIf"
value = condition
}
) {
when {
condition -> then(ifTrue(Modifier))
ifFalse != null -> then(ifFalse(Modifier))
else -> this
}
}

View file

@ -0,0 +1,106 @@
/*
* 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)
}

View file

@ -21,12 +21,12 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ModalBottomSheetDefaults
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.contentColorFor
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -44,7 +44,7 @@ fun ModalBottomSheetLayout(
sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden),
sheetShape: Shape = MaterialTheme.shapes.large,
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
sheetBackgroundColor: Color = MaterialTheme.colors.surface,
sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
content: @Composable () -> Unit = {}

View file

@ -0,0 +1,39 @@
/*
* 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.utils
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
@Composable
fun WindowInsets.copy(
top: Int? = null,
right: Int? = null,
bottom: Int? = null,
left: Int? = null
): WindowInsets {
val density = LocalDensity.current
val direction = LocalLayoutDirection.current
return WindowInsets(
top = top ?: this.getTop(density),
right = right ?: this.getRight(density, direction),
bottom = bottom ?: this.getBottom(density),
left = left ?: this.getLeft(density, direction)
)
}

View file

@ -36,6 +36,8 @@ interface MatrixClient : Closeable {
val mediaLoader: MatrixMediaLoader
fun getRoom(roomId: RoomId): MatrixRoom?
fun findDM(userId: UserId): MatrixRoom?
suspend fun ignoreUser(userId: UserId): Result<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>
suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId>
suspend fun createDM(userId: UserId): Result<RoomId>
fun startSync()

View file

@ -16,12 +16,19 @@
package io.element.android.libraries.matrix.api.notification
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
//TODO add content
data class NotificationData(
val item: MatrixTimelineItem,
val title: String,
val subtitle: String?,
val senderId: UserId,
val eventId: EventId,
val roomId: RoomId,
val senderAvatarUrl: String? = null,
val senderDisplayName: String? = null,
val roomAvatarUrl: String? = null,
val isDirect: Boolean,
val isEncrypted: Boolean,
val isNoisy: Boolean,
val avatarUrl: String?,
)

View file

@ -18,12 +18,15 @@ package io.element.android.libraries.matrix.api.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
interface MatrixRoom : Closeable {
val sessionId: SessionId
val roomId: RoomId
val name: String?
val bestName: String
@ -36,20 +39,22 @@ interface MatrixRoom : Closeable {
val isDirect: Boolean
val isPublic: Boolean
suspend fun members(): List<RoomMember>
/**
* The current loaded members as a StateFlow.
* Initial value is [MatrixRoomMembersState.Unknown].
* To update them you should call [updateMembers].
*/
val membersStateFlow: StateFlow<MatrixRoomMembersState>
suspend fun memberCount(): Int
fun getMember(userId: UserId): RoomMember?
fun getDmMember(): RoomMember?
/**
* Try to load the room members and update the membersFlow.
*/
suspend fun updateMembers(): Result<Unit>
fun syncUpdateFlow(): Flow<Long>
fun timeline(): MatrixTimeline
suspend fun fetchMembers(): Result<Unit>
suspend fun userDisplayName(userId: UserId): Result<String?>
suspend fun userAvatarUrl(userId: UserId): Result<String?>

View file

@ -0,0 +1,35 @@
/*
* 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.matrix.api.room
sealed interface MatrixRoomMembersState {
object Unknown : MatrixRoomMembersState
data class Pending(val prevRoomMembers: List<RoomMember>? = null) : MatrixRoomMembersState
data class Error(val failure: Throwable, val prevRoomMembers: List<RoomMember>? = null) : MatrixRoomMembersState
data class Ready(val roomMembers: List<RoomMember>) : MatrixRoomMembersState
}
fun MatrixRoomMembersState.roomMembers(): List<RoomMember>? {
return when (this) {
is MatrixRoomMembersState.Ready -> roomMembers
is MatrixRoomMembersState.Pending -> prevRoomMembers
is MatrixRoomMembersState.Error -> prevRoomMembers
else -> null
}
}

View file

@ -16,11 +16,8 @@
package io.element.android.libraries.matrix.api.room
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.parcelize.Parcelize
@Parcelize
data class RoomMember(
val userId: UserId,
val displayName: String?,
@ -30,7 +27,7 @@ data class RoomMember(
val powerLevel: Long,
val normalizedPowerLevel: Long,
val isIgnored: Boolean,
) : Parcelable
)
enum class RoomMembershipState {
BAN, INVITE, JOIN, KNOCK, LEAVE

View file

@ -200,7 +200,7 @@ class RustMatrixClient constructor(
val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null
val fullRoom = slidingSyncRoom.fullRoom() ?: return null
return RustMatrixRoom(
currentUserId = sessionId,
sessionId = sessionId,
slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow,
slidingSyncRoom = slidingSyncRoom,
innerRoom = fullRoom,
@ -214,6 +214,18 @@ class RustMatrixClient constructor(
return roomId?.let { getRoom(it) }
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(dispatchers.io) {
runCatching {
client.ignoreUser(userId.value)
}
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> = withContext(dispatchers.io) {
runCatching {
client.unignoreUser(userId.value)
}
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = withContext(dispatchers.io) {
runCatching {
val rustParams = RustCreateRoomParameters(

View file

@ -63,7 +63,7 @@ class RustMediaLoader(
runCatching {
mediaSourceFromUrl(url).use { mediaSource ->
innerClient.getMediaFile(
source = mediaSource,
mediaSource = mediaSource,
mimeType = mimeType ?: "application/octet-stream"
).use {
Path(it.path())

View file

@ -16,35 +16,28 @@
package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.matrix.api.core.EventId
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.notification.NotificationData
import io.element.android.libraries.matrix.impl.timeline.MatrixTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.use
import javax.inject.Inject
class NotificationMapper @Inject constructor() {
// TODO Inject and remove duplicate?
private val timelineItemFactory = MatrixTimelineItemMapper(
virtualTimelineItemMapper = VirtualTimelineItemMapper(),
eventTimelineItemMapper = EventTimelineItemMapper(
contentMapper = TimelineEventContentMapper(
eventMessageMapper = EventMessageMapper()
)
)
)
fun map(notificationItem: NotificationItem): NotificationData {
return notificationItem.use {
NotificationData(
item = timelineItemFactory.map(it.item),
title = it.title,
subtitle = it.subtitle,
isNoisy = it.isNoisy,
avatarUrl = it.avatarUrl,
senderId = UserId(it.event.senderId()),
eventId = EventId(it.event.eventId()),
roomId = RoomId(it.roomId),
senderAvatarUrl = it.senderAvatarUrl,
senderDisplayName = it.senderDisplayName,
roomAvatarUrl = it.roomAvatarUrl,
isDirect = it.isDirect,
isEncrypted = it.isEncrypted,
isNoisy = it.isNoisy
)
}
}

View file

@ -17,21 +17,23 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
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.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.SlidingSyncRoom
@ -40,7 +42,7 @@ import org.matrix.rustcomponents.sdk.genTransactionId
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
class RustMatrixRoom(
private val currentUserId: UserId,
override val sessionId: SessionId,
private val slidingSyncUpdateFlow: Flow<UpdateSummary>,
private val slidingSyncRoom: SlidingSyncRoom,
private val innerRoom: Room,
@ -48,38 +50,19 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixRoom {
private var loadMembersJob: Job? = null
private var cachedMembers: List<RoomMember> = emptyList()
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
get() = _membersStateFlow
override suspend fun members(): List<RoomMember> {
return cachedMembers.ifEmpty {
if (loadMembersJob == null) {
loadMembersJob = coroutineScope.launch(coroutineDispatchers.io) {
cachedMembers = tryOrNull {
innerRoom.members().map(RoomMemberMapper::map)
} ?: emptyList()
}
}
loadMembersJob?.join()
loadMembersJob = null
cachedMembers
}
}
private var _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown)
override suspend fun memberCount(): Int {
return members().size
}
override fun getMember(userId: UserId): RoomMember? {
return cachedMembers.find { it.userId == userId }
}
override fun getDmMember(): RoomMember? {
return if (cachedMembers.size == 2 && isDirect && isEncrypted) {
cachedMembers.find { it.userId != currentUserId }
} else {
null
}
private val timeline by lazy {
RustMatrixTimeline(
matrixRoom = this,
innerRoom = innerRoom,
slidingSyncRoom = slidingSyncRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers
)
}
override fun syncUpdateFlow(): Flow<Long> {
@ -94,13 +77,7 @@ class RustMatrixRoom(
}
override fun timeline(): MatrixTimeline {
return RustMatrixTimeline(
matrixRoom = this,
innerRoom = innerRoom,
slidingSyncRoom = slidingSyncRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = coroutineDispatchers
)
return timeline
}
override fun close() {
@ -150,9 +127,16 @@ class RustMatrixRoom(
override val isDirect: Boolean
get() = innerRoom.isDirect()
override suspend fun fetchMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun updateMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
val currentState = _membersStateFlow.value
val currentMembers = currentState.roomMembers()
_membersStateFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = currentMembers)
runCatching {
innerRoom.fetchMembers()
innerRoom.members().map(RoomMemberMapper::map)
}.map {
_membersStateFlow.value = MatrixRoomMembersState.Ready(it)
}.onFailure {
_membersStateFlow.value = MatrixRoomMembersState.Error(prevRoomMembers = currentMembers, failure = it)
}
}
@ -208,13 +192,13 @@ class RustMatrixRoom(
}
override suspend fun acceptInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) {
kotlin.runCatching {
runCatching {
innerRoom.acceptInvitation()
}
}
override suspend fun rejectInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) {
kotlin.runCatching {
runCatching {
innerRoom.rejectInvitation()
}
}

View file

@ -42,6 +42,7 @@ import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineListener
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
class RustMatrixTimeline(
private val matrixRoom: MatrixRoom,
@ -51,6 +52,8 @@ class RustMatrixTimeline(
private val coroutineDispatchers: CoroutineDispatchers,
) : MatrixTimeline {
private val isInit = AtomicBoolean(false)
private val timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
MutableStateFlow(emptyList())
@ -95,6 +98,7 @@ class RustMatrixTimeline(
withContext(coroutineDispatchers.diffUpdateDispatcher) {
this@RustMatrixTimeline.timelineItems.value = matrixTimelineItems
}
isInit.set(true)
}
.onFailure {
Timber.e("Failed adding timeline listener on room with identifier: ${matrixRoom.roomId})")
@ -105,6 +109,7 @@ class RustMatrixTimeline(
override fun dispose() {
Timber.v("Dispose timeline for room ${matrixRoom.roomId}")
listenerTokens.dispose()
isInit.set(false)
}
/**
@ -125,6 +130,9 @@ class RustMatrixTimeline(
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
if (!isInit.get()) {
throw IllegalStateException("Timeline is not init yet")
}
val paginationOptions = PaginationOptions.UntilNumItems(
eventLimit = requestSize.toUShort(),
items = untilNumberOfItems.toUShort()
@ -139,10 +147,26 @@ class RustMatrixTimeline(
private suspend fun addListener(timelineListener: TimelineListener): Result<List<TimelineItem>> = withContext(coroutineDispatchers.io) {
runCatching {
val settings = RoomSubscription(requiredState = listOf(RequiredState(key = "m.room.canonical_alias", value = "")), timelineLimit = null)
val settings = RoomSubscription(
requiredState = listOf(
RequiredState(key = "m.room.canonical_alias", value = ""),
RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.join_rules", value = ""),
),
timelineLimit = null
)
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings)
launch {
fetchMembers()
}
listenerTokens += result.taskHandle
result.items
}
}
private suspend fun fetchMembers() = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.fetchMembers()
}
}
}

View file

@ -23,6 +23,7 @@ android {
}
dependencies {
api(projects.libraries.core)
api(projects.libraries.matrix.api)
api(libs.coroutines.core)
}

View file

@ -48,6 +48,8 @@ class FakeMatrixClient(
private val notificationService: FakeNotificationService = FakeNotificationService(),
) : MatrixClient {
private var ignoreUserResult: Result<Unit> = Result.success(Unit)
private var unignoreUserResult: Result<Unit> = Result.success(Unit)
private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmFailure: Throwable? = null
@ -63,6 +65,14 @@ class FakeMatrixClient(
return findDmResult
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> {
return ignoreUserResult
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> {
return unignoreUserResult
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> {
delay(100)
return createRoomResult
@ -119,6 +129,14 @@ class FakeMatrixClient(
createDmResult = result
}
fun givenIgnoreUserResult(result: Result<Unit>) {
ignoreUserResult = result
}
fun givenUnignoreUserResult(result: Result<Unit>) {
unignoreUserResult = result
}
fun givenCreateDmError(failure: Throwable?) {
createDmFailure = failure
}

View file

@ -18,17 +18,21 @@ package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
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.MatrixRoomMembersState
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.timeline.FakeMatrixTimeline
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
class FakeMatrixRoom(
override val sessionId: SessionId = A_SESSION_ID,
override val roomId: RoomId = A_ROOM_ID,
override val name: String? = null,
override val bestName: String = "",
@ -40,19 +44,16 @@ class FakeMatrixRoom(
override val alternativeAliases: List<String> = emptyList(),
override val isPublic: Boolean = true,
override val isDirect: Boolean = false,
private val members: List<RoomMember> = emptyList(),
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
) : MatrixRoom {
private var ignoreResult: Result<Unit> = Result.success(Unit)
private var unignoreResult: Result<Unit> = Result.success(Unit)
private var userDisplayNameResult = Result.success<String?>(null)
private var userAvatarUrlResult = Result.success<String?>(null)
private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var acceptInviteResult = Result.success(Unit)
private var rejectInviteResult = Result.success(Unit)
private var dmMember: RoomMember? = null
private var fetchMemberResult: Result<Unit> = Result.success(Unit)
var areMembersFetched: Boolean = false
private set
var isInviteAccepted: Boolean = false
private set
@ -62,6 +63,12 @@ class FakeMatrixRoom(
private var leaveRoomError: Throwable? = null
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
override suspend fun updateMembers(): Result<Unit> {
return updateMembersResult
}
override fun syncUpdateFlow(): Flow<Long> {
return emptyFlow()
}
@ -70,18 +77,6 @@ class FakeMatrixRoom(
return matrixTimeline
}
override suspend fun fetchMembers(): Result<Unit> {
return fetchMemberResult.also { result ->
if (result.isSuccess) {
areMembersFetched = true
}
}
}
override fun getDmMember(): RoomMember? {
return dmMember
}
override suspend fun userDisplayName(userId: UserId): Result<String?> {
return userDisplayNameResult
}
@ -90,22 +85,6 @@ class FakeMatrixRoom(
return userAvatarUrlResult
}
override suspend fun members(): List<RoomMember> {
return members
}
override suspend fun memberCount(): Int {
if (fetchMemberResult.isSuccess) {
return members.count()
} else {
throw fetchMemberResult.exceptionOrNull()!!
}
}
override fun getMember(userId: UserId): RoomMember? {
return members.firstOrNull { it.userId == userId }
}
override suspend fun sendMessage(message: String): Result<Unit> {
delay(100)
return Result.success(Unit)
@ -155,12 +134,12 @@ class FakeMatrixRoom(
this.leaveRoomError = throwable
}
fun givenFetchMemberResult(result: Result<Unit>) {
fetchMemberResult = result
fun givenRoomMembersState(state: MatrixRoomMembersState) {
membersStateFlow.value = state
}
fun givenDmMember(roomMember: RoomMember) {
this.dmMember = roomMember
fun givenUpdateMembersResult(result: Result<Unit>) {
updateMembersResult = result
}
fun givenUserDisplayNameResult(displayName: Result<String?>) {
@ -179,4 +158,11 @@ class FakeMatrixRoom(
rejectInviteResult = result
}
fun givenIgnoreResult(result: Result<Unit>) {
ignoreResult = result
}
fun givenUnIgnoreResult(result: Result<Unit>) {
unignoreResult = result
}
}

View file

@ -0,0 +1,70 @@
/*
* 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.matrix.ui.room
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
@Composable
fun MatrixRoom.getRoomMember(userId: UserId): State<RoomMember?> {
val roomMembersState by membersStateFlow.collectAsState()
return getRoomMember(roomMembersState = roomMembersState, userId = userId)
}
@Composable
fun getRoomMember(roomMembersState: MatrixRoomMembersState, userId: UserId): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
return remember(roomMembers) {
derivedStateOf {
roomMembers?.find {
it.userId == userId
}
}
}
}
@Composable
fun MatrixRoom.getDirectRoomMember(): State<RoomMember?> {
val roomMembersState by membersStateFlow.collectAsState()
return getDirectRoomMember(roomMembersState = roomMembersState)
}
@Composable
fun MatrixRoom.getDirectRoomMember(roomMembersState: MatrixRoomMembersState): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
return remember(roomMembers) {
derivedStateOf {
if (roomMembers == null) {
null
} else if (roomMembers.size == 2 && isDirect && isEncrypted) {
roomMembers.find { it.userId != this.sessionId }
} else {
null
}
}
}
}

View file

@ -0,0 +1,34 @@
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.mediapickers"
dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.core)
implementation(libs.inject)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediapickers
import androidx.activity.compose.ManagedActivityResultLauncher
/**
* Wrapper around [ManagedActivityResultLauncher] to be used with media/file pickers.
*/
interface PickerLauncher<Input, Output> {
/** Starts the activity result launcher with its default input. */
fun launch()
/** Starts the activity result launcher with a [customInput]. */
fun launch(customInput: Input)
}
class ComposePickerLauncher<Input, Output>(
private val managedLauncher: ManagedActivityResultLauncher<Input, Output>,
private val defaultRequest: Input,
) : PickerLauncher<Input, Output> {
override fun launch() {
managedLauncher.launch(defaultRequest)
}
override fun launch(customInput: Input) {
managedLauncher.launch(customInput)
}
}
/** Needed for screenshot tests. */
class NoOpPickerLauncher<Input, Output>(
private val onResult: () -> Unit,
) : PickerLauncher<Input, Output> {
override fun launch() = onResult()
override fun launch(customInput: Input) = onResult()
}

View file

@ -0,0 +1,150 @@
/*
* 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.mediapickers
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import java.io.File
import java.util.UUID
import javax.inject.Inject
class PickerProvider constructor(private val isInTest: Boolean) {
@Inject
constructor(): this(false)
/**
* Remembers and returns a [PickerLauncher] for a certain media/file [type].
*/
@Composable
internal fun <Input, Output> rememberPickerLauncher(
type: PickerType<Input, Output>,
onResult: (Output) -> Unit,
): PickerLauncher<Input, Output> {
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { }
} else {
val contract = type.getContract()
val managedLauncher = rememberLauncherForActivityResult(contract = contract, onResult = onResult)
remember(type) { ComposePickerLauncher(managedLauncher, type.getDefaultRequest()) }
}
}
/**
* Remembers and returns a [PickerLauncher] for a gallery item, either a picture or a video.
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
*/
@Composable
fun registerGalleryPicker(onResult: (Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri -> onResult(uri) }
}
}
/**
* Remembers and returns a [PickerLauncher] for a file of a certain [mimeType] (any type of file, by default).
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
*/
@Composable
fun registerFilePicker(mimeType: String = MimeTypes.Any, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
rememberPickerLauncher(type = PickerType.File(mimeType)) { uri -> onResult(uri) }
}
}
/**
* Remembers and returns a [PickerLauncher] for taking a photo with a camera app.
* @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected.
* @param [deleteAfter] When it's `true`, the taken photo will be automatically removed after calling [onResult].
* It's `true` by default.
*/
@Composable
fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher<Uri, Boolean> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
val context = LocalContext.current
val tmpFile = remember { getTemporaryFile(context) }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
// Then remove the file and clear the picker
if (deleteAfter) {
tmpFile.delete()
}
}
}
}
/**
* Remembers and returns a [PickerLauncher] for recording a video with a camera app.
* @param [onResult] will be called with either the video's [Uri] or `null` if nothing was selected.
* @param [deleteAfter] When it's `true`, the recorded video will be automatically removed after calling [onResult].
* It's `true` by default.
*/
@Composable
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher<Uri, Boolean> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
val context = LocalContext.current
val tmpFile = remember { getTemporaryFile(context) }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
// Then remove the file and clear the picker
if (deleteAfter) {
tmpFile.delete()
}
}
}
}
private fun getTemporaryFile(
context: Context,
baseFolder: File = context.cacheDir,
filename: String = UUID.randomUUID().toString(),
): File {
return File(baseFolder, filename)
}
private fun getTemporaryUri(
context: Context,
file: File,
): Uri {
val authority = "${context.packageName}.fileprovider"
return FileProvider.getUriForFile(context, authority, file)
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediapickers
import android.net.Uri
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import io.element.android.libraries.core.mimetype.MimeTypes
sealed interface PickerType<Input, Output> {
fun getContract(): ActivityResultContract<Input, Output>
fun getDefaultRequest(): Input
object ImageAndVideo : PickerType<PickVisualMediaRequest, Uri?> {
override fun getContract() = ActivityResultContracts.PickVisualMedia()
override fun getDefaultRequest(): PickVisualMediaRequest {
return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
}
}
object Camera {
data class Photo(val destUri: Uri) : PickerType<Uri, Boolean> {
override fun getContract() = ActivityResultContracts.TakePicture()
override fun getDefaultRequest(): Uri {
return destUri
}
}
data class Video(val destUri: Uri) : PickerType<Uri, Boolean> {
override fun getContract() = ActivityResultContracts.CaptureVideo()
override fun getDefaultRequest(): Uri {
return destUri
}
}
}
data class File(val mimeType: String = MimeTypes.Any) : PickerType<String, Uri?> {
override fun getContract() = ActivityResultContracts.GetContent()
override fun getDefaultRequest(): String {
return mimeType
}
}
}

View file

@ -0,0 +1,65 @@
/*
* 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.mediapickers
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContracts
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PickerTypeTests {
@Test
fun `ImageAndVideo - assert types`() {
val pickerType = PickerType.ImageAndVideo
assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.PickVisualMedia::class.java)
assertThat(pickerType.getDefaultRequest().mediaType).isEqualTo(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
}
@Test
fun `File - assert types`() {
val pickerType = PickerType.File()
assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java)
assertThat(pickerType.getDefaultRequest()).isEqualTo(MimeTypes.Any)
val mimeType = MimeTypes.Images
val customPickerType = PickerType.File(mimeType)
assertThat(customPickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java)
assertThat(customPickerType.getDefaultRequest()).isEqualTo(mimeType)
}
@Test
fun `CameraPhoto - assert types`() {
val uri = Uri.parse("file:///tmp/test")
val pickerType = PickerType.Camera.Photo(uri)
assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.TakePicture::class.java)
assertThat(pickerType.getDefaultRequest()).isEqualTo(uri)
}
@Test
fun `CameraVideo - assert types`() {
val uri = Uri.parse("file:///tmp/test")
val pickerType = PickerType.Camera.Video(uri)
assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.CaptureVideo::class.java)
assertThat(pickerType.getDefaultRequest()).isEqualTo(uri)
}
}

View file

@ -24,11 +24,6 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -74,28 +69,28 @@ class NotifiableEventResolver @Inject constructor(
}
).orDefault(roomId, eventId)
return notificationData.asNotifiableEvent(sessionId, roomId, eventId)
return notificationData.asNotifiableEvent(sessionId)
}
}
private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent {
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent {
return NotifiableMessageEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
editedEventId = null,
canBeReplaced = true,
noisy = false,
noisy = isNoisy,
timestamp = System.currentTimeMillis(),
senderName = null,
senderId = null,
senderName = senderDisplayName,
senderId = senderId.value,
body = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}",
imageUriString = null,
threadId = null,
roomName = null,
roomIsDirect = false,
roomAvatarPath = null,
senderAvatarPath = null,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
soundName = null,
outGoingMessage = false,
outGoingMessageFailed = false,
@ -109,33 +104,11 @@ private fun NotificationData.asNotifiableEvent(userId: SessionId, roomId: RoomId
*/
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
return this ?: NotificationData(
item = MatrixTimelineItem.Event(
event = EventTimelineItem(
uniqueIdentifier = eventId.value,
eventId = eventId,
isEditable = false,
isLocal = false,
isOwn = false,
isRemote = false,
localSendState = null,
reactions = emptyList(),
sender = UserId(""),
senderProfile = ProfileTimelineDetails.Unavailable,
timestamp = System.currentTimeMillis(),
content = MessageContent(
body = eventId.value,
inReplyTo = null,
isEdited = false,
type = TextMessageType(
body = eventId.value,
formatted = null
)
)
),
),
title = roomId.value,
subtitle = eventId.value,
eventId = eventId,
senderId = UserId("@user:domain"),
roomId = roomId,
isNoisy = false,
avatarUrl = null,
isEncrypted = false,
isDirect = false
)
}

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_noisy">"Laute Benachrichtigungen"</string>
<string name="notification_invitation_action_join">"Beitreten"</string>
<string name="notification_invitation_action_reject">"Ablehnen"</string>
<string name="notification_new_messages">"Neue Nachrichten"</string>
<string name="notification_room_action_mark_as_read">"Als gelesen markieren"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s in %2$s und %3$s"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d Nachricht"</item>
<item quantity="other">"%1$s: %2$d Nachrichten"</item>
</plurals>
<plurals name="notification_invitations">
<item quantity="one">"%d Einladung"</item>
<item quantity="other">"%d Einladungen"</item>
</plurals>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d neue Nachricht"</item>
<item quantity="other">"%d neue Nachrichten"</item>
</plurals>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d Raum"</item>
<item quantity="other">"%d Räume"</item>
</plurals>
<string name="push_distributor_firebase_android">"Google-Dienste"</string>
<string name="push_no_valid_google_play_services_apk_android">"Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig."</string>
<string name="notification_room_action_quick_reply">"Schnellantwort"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_room_action_quick_reply">"Respuesta rápida"</string>
</resources>

View file

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

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Apel"</string>
<string name="notification_channel_listening_for_events">"Ascultare evenimente"</string>
<string name="notification_channel_noisy">"Notificări zgomotoase"</string>
<string name="notification_channel_silent">"Notificări silențioase"</string>
<string name="notification_inline_reply_failed">"** Trimiterea eșuată - vă rugăm să deschideți camera"</string>
<string name="notification_invitation_action_join">"Alăturați-vă"</string>
<string name="notification_invitation_action_reject">"Respingeți"</string>
<string name="notification_new_messages">"Mesaje noi"</string>
<string name="notification_room_action_mark_as_read">"Marcați ca citit"</string>
<string name="notification_sender_me">"Eu"</string>
<string name="notification_test_push_notification_content">"Vizualizați o notificare! Faceți clic pe mine!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_and_invitation">"%1$s și %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s în %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s în %2$s și %3$s"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d mesaj"</item>
<item quantity="few"></item>
<item quantity="other">"%1$s: %2$d mesaje"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d notificare"</item>
<item quantity="few"></item>
<item quantity="other">"%d notificări"</item>
</plurals>
<plurals name="notification_invitations">
<item quantity="one">"%d invitație"</item>
<item quantity="few"></item>
<item quantity="other">"%d invitații"</item>
</plurals>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d mesaj nou"</item>
<item quantity="few"></item>
<item quantity="other">"%d mesaje noi"</item>
</plurals>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d mesaj notificat necitit"</item>
<item quantity="few"></item>
<item quantity="other">"%d mesaje notificate necitite"</item>
</plurals>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d cameră"</item>
<item quantity="few"></item>
<item quantity="other">"%d camere"</item>
</plurals>
<string name="push_choose_distributor_dialog_title_android">"Alegeți modul de primire a notificărilor"</string>
<string name="push_distributor_background_sync_android">"Sincronizare în fundal"</string>
<string name="push_distributor_firebase_android">"Servicii Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Nu au fost găsite servicii Google Play valide. Este posibil ca notificările să nu funcționeze corect."</string>
<string name="notification_room_action_quick_reply">"Raspuns rapid"</string>
</resources>

View file

@ -9,7 +9,6 @@
<string name="notification_invitation_action_reject">"Reject"</string>
<string name="notification_new_messages">"New Messages"</string>
<string name="notification_room_action_mark_as_read">"Mark as read"</string>
<string name="notification_room_action_quick_reply">"Quick reply"</string>
<string name="notification_sender_me">"Me"</string>
<string name="notification_test_push_notification_content">"You are viewing the notification! Click me!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
@ -45,4 +44,5 @@
<string name="push_distributor_background_sync_android">"Background synchronization"</string>
<string name="push_distributor_firebase_android">"Google Services"</string>
<string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string>
<string name="notification_room_action_quick_reply">"Quick reply"</string>
</resources>

View file

@ -38,6 +38,11 @@ object TestTags {
*/
val changeServerServer = TestTag("change_server-server")
val changeServerContinue = TestTag("change_server-continue")
/**
* Room list / Home screen.
*/
val homeScreenSettings = TestTag("home_screen-settings")
}

View file

@ -50,6 +50,7 @@ fun TextComposer(
onFullscreenToggle: () -> Unit = {},
onCloseSpecialMode: () -> Unit = {},
onComposerTextChange: (CharSequence) -> Unit = {},
onAddAttachment:() -> Unit = {},
) {
if (LocalInspectionMode.current) {
FakeComposer(modifier)
@ -78,6 +79,7 @@ fun TextComposer(
}
override fun onAddAttachment() {
onAddAttachment()
}
override fun onExpandOrCompactChange() {

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_composer_placeholder">"Nachricht…"</string>
<string name="rich_text_editor_link">"Link setzen"</string>
</resources>

View file

@ -1,5 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_hide_password">"Passwort ausblenden"</string>
<string name="a11y_send_files">"Dateien senden"</string>
<string name="a11y_show_password">"Passwort anzeigen"</string>
<string name="a11y_user_menu">"Benutzermenü"</string>
<string name="action_back">"Zurück"</string>
<string name="action_cancel">"Abbrechen"</string>
<string name="action_choose_photo">"Foto auswählen"</string>
<string name="action_close">"Schließen"</string>
<string name="action_complete_verification">"Verifizierung abschließen"</string>
<string name="action_confirm">"Bestätigen"</string>
<string name="action_copy">"Kopieren"</string>
<string name="action_copy_link">"Link kopieren"</string>
<string name="action_create">"Erstellen"</string>
<string name="action_decline">"Ablehnen"</string>
<string name="action_disable">"Deaktivieren"</string>
<string name="action_done">"Fertig"</string>
<string name="action_edit">"Bearbeiten"</string>
<string name="action_enable">"Aktivieren"</string>
<string name="action_invite">"Einladen"</string>
<string name="action_invite_friends_to_app">"Freunde zu %1$s einladen"</string>
<string name="action_invites_list">"Einladungen"</string>
<string name="action_learn_more">"Mehr erfahren"</string>
<string name="action_leave">"Verlassen"</string>
<string name="action_leave_room">"Raum verlassen"</string>
<string name="action_next">"Weiter"</string>
<string name="action_no">"Nein"</string>
<string name="action_ok">"OK"</string>
<string name="action_quick_reply">"Schnellantwort"</string>
<string name="action_quote">"Zitieren"</string>
<string name="action_remove">"Entfernen"</string>
<string name="action_report_bug">"Fehler melden"</string>
<string name="action_report_content">"Inhalt melden"</string>
<string name="action_retry">"Erneut versuchen"</string>
<string name="action_retry_decryption">"Entschlüsselung erneut versuchen"</string>
<string name="action_save">"Speichern"</string>
<string name="action_search">"Suchen"</string>
<string name="action_send">"Senden"</string>
<string name="action_send_message">"Nachricht senden"</string>
<string name="action_share">"Teilen"</string>
<string name="action_share_link">"Link teilen"</string>
<string name="action_skip">"Überspringen"</string>
<string name="action_take_photo">"Foto aufnehmen"</string>
<string name="action_yes">"Ja"</string>
<string name="common_about">"Über"</string>
<string name="common_analytics">"Analytik"</string>
<string name="common_audio">"Audio"</string>
<string name="common_bubbles">"Blasen"</string>
<string name="common_decryption_error">"Entschlüsselungsfehler"</string>
<string name="common_developer_options">"Entwickleroptionen"</string>
<string name="common_edited_suffix">"(bearbeitet)"</string>
<string name="common_encryption_enabled">"Verschlüsselung aktiviert"</string>
<string name="common_error">"Fehler"</string>
<string name="common_file">"Datei"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Bild"</string>
<string name="common_link_copied_to_clipboard">"Link in Zwischenablage kopiert"</string>
<string name="common_message">"Nachricht"</string>
<string name="common_modern">"Modern"</string>
<string name="common_offline">"Offline"</string>
<string name="common_password">"Passwort"</string>
<string name="common_reactions">"Reaktionen"</string>
<string name="common_security">"Sicherheit"</string>
<string name="common_settings">"Einstellungen"</string>
<string name="common_sticker">"Sticker"</string>
<string name="common_success">"Erfolg"</string>
<string name="common_suggestions">"Vorschläge"</string>
<string name="common_topic">"Thema"</string>
<string name="common_unable_to_decrypt">"Entschlüsselung nicht möglich"</string>
<string name="common_unsupported_event">"Nicht unterstütztes Ereignis"</string>
<string name="common_username">"Benutzername"</string>
<string name="common_verification_cancelled">"Verifizierung abgebrochen"</string>
<string name="common_verification_complete">"Verifizierung abgeschlossen"</string>
<string name="common_video">"Video"</string>
<string name="common_waiting">"Warten…"</string>
<string name="dialog_title_warning">"Warnung"</string>
<string name="emoji_picker_category_activity">"Aktivitäten"</string>
<string name="emoji_picker_category_flags">"Flaggen"</string>
<string name="emoji_picker_category_foods">"Essen &amp; Trinken"</string>
<string name="emoji_picker_category_nature">"Tiere &amp; Natur"</string>
<string name="emoji_picker_category_objects">"Objekte"</string>
<string name="emoji_picker_category_people">"Smileys &amp; Personen"</string>
<string name="emoji_picker_category_places">"Reisen &amp; Orte"</string>
<string name="emoji_picker_category_symbols">"Symbole"</string>
<string name="error_failed_loading_messages">"Fehler beim Laden der Nachrichten"</string>
<string name="error_unknown">"Entschuldigung, ein Fehler ist aufgetreten."</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d Mitglied"</item>
<item quantity="other">"%1$d Mitglieder"</item>
</plurals>
<string name="report_content_hint">"Grund für die Meldung dieses Inhalts"</string>
<string name="room_timeline_beginning_of_room">"Dies ist der Anfang von %1$s."</string>
<string name="room_timeline_read_marker_title">"Neu"</string>
<string name="screen_room_member_details_block_alert_action">"Blockieren"</string>
<string name="screen_room_member_details_block_user">"Nutzer blockieren"</string>
<string name="screen_room_member_details_unblock_alert_action">"Blockierung aufheben"</string>
<string name="screen_room_member_details_unblock_user">"Nutzer entblockieren"</string>
<string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string>
<string name="settings_version_number">"Version: %1$s (%2$s)"</string>
<string name="test_language_identifier">"de"</string>
<string name="dialog_title_error">"Fehler"</string>
<string name="dialog_title_success">"Erfolg"</string>
<string name="screen_report_content_block_user">"Nutzer blockieren"</string>
</resources>

View file

@ -94,8 +94,6 @@
<string name="common_video">"Vídeo"</string>
<string name="common_waiting">"Esperando…"</string>
<string name="dialog_title_confirmation">"Confirmar"</string>
<string name="dialog_title_error">"Error"</string>
<string name="dialog_title_success">"Terminado"</string>
<string name="dialog_title_warning">"Atención"</string>
<string name="emoji_picker_category_activity">"Actividades"</string>
<string name="emoji_picker_category_flags">"Banderas"</string>
@ -129,7 +127,6 @@
<string name="room_timeline_beginning_of_room">"Este es el principio de %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"Este es el principio de esta conversación."</string>
<string name="room_timeline_read_marker_title">"Nuevos"</string>
<string name="screen_report_content_block_user">"Bloquear usuario"</string>
<string name="screen_report_content_block_user_hint">"Marque si quieres ocultar todos los mensajes actuales y futuros de este usuario"</string>
<string name="screen_room_member_details_block_alert_action">"Bloquear"</string>
<string name="screen_room_member_details_block_alert_description">"Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puede revertir esta acción en cualquier momento."</string>
@ -142,4 +139,7 @@
<string name="settings_title_general">"General"</string>
<string name="settings_version_number">"Versión: %1$s (%2$s)"</string>
<string name="test_language_identifier">"es"</string>
<string name="dialog_title_error">"Error"</string>
<string name="dialog_title_success">"Terminado"</string>
<string name="screen_report_content_block_user">"Bloquear usuario"</string>
</resources>

View file

@ -94,8 +94,6 @@
<string name="common_video">"Video"</string>
<string name="common_waiting">"In attesa…"</string>
<string name="dialog_title_confirmation">"Conferma"</string>
<string name="dialog_title_error">"Errore"</string>
<string name="dialog_title_success">"Operazione riuscita"</string>
<string name="dialog_title_warning">"Attenzione"</string>
<string name="emoji_picker_category_activity">"Attività"</string>
<string name="emoji_picker_category_flags">"Bandiere"</string>
@ -129,7 +127,6 @@
<string name="room_timeline_beginning_of_room">"Questo è l\'inizio di %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"Questo è l\'inizio della conversazione."</string>
<string name="room_timeline_read_marker_title">"Nuovo"</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>
<string name="screen_report_content_block_user_hint">"Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"</string>
<string name="screen_room_member_details_block_alert_action">"Blocca"</string>
<string name="screen_room_member_details_block_alert_description">"Gli utenti bloccati non saranno in grado di inviarti nuovi messaggi e tutti quelli già esistenti saranno nascosti. Potrai annullare questa azione in qualsiasi momento."</string>
@ -142,4 +139,7 @@
<string name="settings_title_general">"Generali"</string>
<string name="settings_version_number">"Versione: %1$s (%2$s)"</string>
<string name="test_language_identifier">"it"</string>
<string name="dialog_title_error">"Errore"</string>
<string name="dialog_title_success">"Operazione riuscita"</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>
</resources>

View file

@ -4,8 +4,10 @@
<string name="a11y_send_files">"Trimiteți fișiere"</string>
<string name="a11y_show_password">"Afișați parola"</string>
<string name="a11y_user_menu">"Meniu utilizator"</string>
<string name="action_accept">"Acceptați"</string>
<string name="action_back">"Înapoi"</string>
<string name="action_cancel">"Anulați"</string>
<string name="action_choose_photo">"Alegeți o fotografie"</string>
<string name="action_clear">"Ștergeți"</string>
<string name="action_close">"Închideți"</string>
<string name="action_complete_verification">"Verificare completă"</string>
@ -13,13 +15,16 @@
<string name="action_continue">"Continuați"</string>
<string name="action_copy">"Copiați"</string>
<string name="action_copy_link">"Copiați linkul"</string>
<string name="action_create">"Creați"</string>
<string name="action_create_a_room">"Creați o cameră"</string>
<string name="action_decline">"Refuzați"</string>
<string name="action_disable">"Dezactivați"</string>
<string name="action_done">"Efectuat"</string>
<string name="action_edit">"Editați"</string>
<string name="action_enable">"Activați"</string>
<string name="action_invite">"Invitați"</string>
<string name="action_invite_friends_to_app">"Invitați prieteni în %1$s"</string>
<string name="action_invites_list">"Invitații"</string>
<string name="action_learn_more">"Aflați mai multe"</string>
<string name="action_leave">"Părăsiți"</string>
<string name="action_leave_room">"Părăsiți camera"</string>
@ -38,15 +43,18 @@
<string name="action_save">"Salvați"</string>
<string name="action_search">"Căutați"</string>
<string name="action_send">"Trimiteți"</string>
<string name="action_send_message">"Trimiteți mesajul"</string>
<string name="action_share">"Partajați"</string>
<string name="action_share_link">"Partajați linkul"</string>
<string name="action_skip">"Omiteți"</string>
<string name="action_start">"Începeți"</string>
<string name="action_start_chat">"Începeți discuția"</string>
<string name="action_start_verification">"Începeți verificarea"</string>
<string name="action_take_photo">"Faceți o fotografie"</string>
<string name="action_view_source">"Vedeți sursă"</string>
<string name="action_yes">"Da"</string>
<string name="common_about">"Despre"</string>
<string name="common_analytics">"Analitice"</string>
<string name="common_audio">"Audio"</string>
<string name="common_bubbles">"Baloane"</string>
<string name="common_creating_room">"Se creează camera…"</string>
@ -94,8 +102,6 @@
<string name="common_video">"Video"</string>
<string name="common_waiting">"Se aşteaptă…"</string>
<string name="dialog_title_confirmation">"Confirmare"</string>
<string name="dialog_title_error">"Eroare"</string>
<string name="dialog_title_success">"Succes"</string>
<string name="dialog_title_warning">"Avertisment"</string>
<string name="emoji_picker_category_activity">"Activități"</string>
<string name="emoji_picker_category_flags">"Steaguri"</string>
@ -131,7 +137,17 @@
<string name="room_timeline_beginning_of_room">"Acesta este începutul conversației %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"Acesta este începutul acestei conversații."</string>
<string name="room_timeline_read_marker_title">"Nou"</string>
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
<string name="screen_analytics_help_us_improve">"Ajutați-ne să identificăm problemele și să îmbunătățim %1$s prin partajarea datelor de utilizare anonime."</string>
<string name="screen_analytics_prompt_data_usage"><b>"Nu"</b>" înregistrăm sau profilăm datele contului"</string>
<string name="screen_analytics_prompt_help_us_improve">"Ajutați-ne să identificăm problemele și să îmbunătățim %1$s prin partajarea datelor de utilizare anonime."</string>
<string name="screen_analytics_prompt_read_terms">"Puteți citi toate condițiile noastre %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"aici"</string>
<string name="screen_analytics_prompt_settings">"Puteți dezactiva această opțiune oricând din setări"</string>
<string name="screen_analytics_prompt_third_party_sharing"><b>"Nu"</b>" împărtășim informații cu terți"</string>
<string name="screen_analytics_prompt_title">"Ajutați la îmbunătățirea %1$s"</string>
<string name="screen_analytics_read_terms">"Puteți citi toate condițiile noastre %1$s."</string>
<string name="screen_analytics_read_terms_content_link">"aici"</string>
<string name="screen_analytics_share_data">"Partajați datele analitice"</string>
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
<string name="screen_room_member_details_block_alert_action">"Blocați"</string>
<string name="screen_room_member_details_block_alert_description">"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."</string>
@ -144,4 +160,7 @@
<string name="settings_title_general">"General"</string>
<string name="settings_version_number">"Versiunea: %1$s (%2$s)"</string>
<string name="test_language_identifier">"ro"</string>
<string name="dialog_title_error">"Eroare"</string>
<string name="dialog_title_success">"Succes"</string>
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
</resources>

View file

@ -102,8 +102,6 @@
<string name="common_video">"Video"</string>
<string name="common_waiting">"Waiting…"</string>
<string name="dialog_title_confirmation">"Confirmation"</string>
<string name="dialog_title_error">"Error"</string>
<string name="dialog_title_success">"Success"</string>
<string name="dialog_title_warning">"Warning"</string>
<string name="emoji_picker_category_activity">"Activities"</string>
<string name="emoji_picker_category_flags">"Flags"</string>
@ -122,60 +120,15 @@
<string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite."</string>
<string name="leave_room_alert_subtitle">"Are you sure that you want to leave the room?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="notification_channel_call">"Call"</string>
<string name="notification_channel_listening_for_events">"Listening for events"</string>
<string name="notification_channel_noisy">"Noisy notifications"</string>
<string name="notification_channel_silent">"Silent notifications"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
<string name="notification_invitation_action_join">"Join"</string>
<string name="notification_invitation_action_reject">"Reject"</string>
<string name="notification_new_messages">"New Messages"</string>
<string name="notification_room_action_mark_as_read">"Mark as read"</string>
<string name="notification_room_action_quick_reply">"Quick reply"</string>
<string name="notification_sender_me">"Me"</string>
<string name="notification_test_push_notification_content">"You are viewing the notification! Click me!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_and_invitation">"%1$s and %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s in %2$s and %3$s"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d member"</item>
<item quantity="other">"%1$d members"</item>
</plurals>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d message"</item>
<item quantity="other">"%1$s: %2$d messages"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d notification"</item>
<item quantity="other">"%d notifications"</item>
</plurals>
<plurals name="notification_invitations">
<item quantity="one">"%d invitation"</item>
<item quantity="other">"%d invitations"</item>
</plurals>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d new message"</item>
<item quantity="other">"%d new messages"</item>
</plurals>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d unread notified message"</item>
<item quantity="other">"%d unread notified messages"</item>
</plurals>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d room"</item>
<item quantity="other">"%d rooms"</item>
</plurals>
<plurals name="room_timeline_state_changes">
<item quantity="one">"%1$d room change"</item>
<item quantity="other">"%1$d room changes"</item>
</plurals>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="push_choose_distributor_dialog_title_android">"Choose how to receive notifications"</string>
<string name="push_distributor_background_sync_android">"Background synchronization"</string>
<string name="push_distributor_firebase_android">"Google Services"</string>
<string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string>
<string name="rageshake_dialog_content">"You seem to be shaking the phone in frustration. Would you like to open the bug report screen?"</string>
<string name="report_content_explanation">"This message will be reported to your homeservers administrator. They will not be able to read any encrypted messages."</string>
<string name="report_content_hint">"Reason for reporting this content"</string>
@ -193,7 +146,6 @@
<string name="screen_analytics_read_terms">"You can read all our terms %1$s."</string>
<string name="screen_analytics_read_terms_content_link">"here"</string>
<string name="screen_analytics_share_data">"Share analytics data"</string>
<string name="screen_report_content_block_user">"Block user"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_room_member_details_block_alert_action">"Block"</string>
<string name="screen_room_member_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
@ -207,4 +159,7 @@
<string name="settings_version_number">"Version: %1$s (%2$s)"</string>
<string name="test_language_identifier">"en"</string>
<string name="test_untranslated_default_language_identifier">"en"</string>
<string name="dialog_title_error">"Error"</string>
<string name="dialog_title_success">"Success"</string>
<string name="screen_report_content_block_user">"Block user"</string>
</resources>