Merge branch 'develop' into feature/fga/space_manage_rooms

This commit is contained in:
ganfra 2026-01-14 17:54:43 +01:00
commit bb082191e4
495 changed files with 5979 additions and 3199 deletions

View file

@ -46,7 +46,7 @@ enum class AvatarSize(val dp: Dp) {
RoomInviteItem(52.dp),
InviteSender(16.dp),
EditRoomDetails(70.dp),
EditRoomDetails(68.dp),
RoomListManageUser(96.dp),
NotificationsOptIn(32.dp),

View file

@ -70,6 +70,13 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
CreateSpaces(
key = "feature.createSpaces",
title = "Create spaces",
description = "Allow creating spaces.",
defaultValue = { false },
isFinished = false,
),
SpaceSettings(
key = "feature.spaceSettings",
title = "Space settings",

View file

@ -26,4 +26,5 @@ data class CreateRoomParameters(
val joinRuleOverride: JoinRule? = null,
val historyVisibilityOverride: RoomHistoryVisibility? = null,
val roomAliasName: Optional<String> = Optional.empty(),
val isSpace: Boolean = false,
)

View file

@ -15,6 +15,10 @@ interface SpaceService {
val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
suspend fun joinedSpaces(): Result<List<SpaceRoom>>
suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>>
suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom?
fun spaceRoomList(id: RoomId): SpaceRoomList
fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle

View file

@ -203,6 +203,7 @@ class RustMatrixClient(
roomMembershipObserver = roomMembershipObserver,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
analyticsService = analyticsService,
)
override val sessionVerificationService = RustSessionVerificationService(
@ -393,6 +394,7 @@ class RustMatrixClient(
joinRuleOverride = createRoomParams.joinRuleOverride?.map(),
historyVisibilityOverride = createRoomParams.historyVisibilityOverride?.map(),
canonicalAlias = createRoomParams.roomAliasName.getOrNull(),
isSpace = createRoomParams.isSpace,
)
val roomId = RoomId(innerClient.createRoom(rustParams))
// Wait to receive the room back from the sync but do not returns failure if it fails.

View file

@ -56,7 +56,7 @@ internal class RoomListFactory(
val loadingStateFlow: MutableStateFlow<RoomList.LoadingState> = MutableStateFlow(RoomList.LoadingState.NotLoaded)
val filteredSummariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val summariesFlow = MutableSharedFlow<List<RoomSummary>>(replay = 1, extraBufferCapacity = 1)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory)
val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService)
// Makes sure we don't miss any events
val dynamicEvents = MutableSharedFlow<RoomListDynamicEvents>(replay = 100)
val currentFilter = MutableStateFlow(initialFilter)

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -18,6 +19,7 @@ import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
import org.matrix.rustcomponents.sdk.RoomListServiceInterface
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import kotlin.collections.groupingBy
import kotlin.coroutines.CoroutineContext
class RoomSummaryListProcessor(
@ -25,26 +27,21 @@ class RoomSummaryListProcessor(
private val roomListService: RoomListServiceInterface,
private val coroutineContext: CoroutineContext,
private val roomSummaryFactory: RoomSummaryFactory,
private val analyticsService: AnalyticsService,
) {
private val mutex = Mutex()
suspend fun postUpdate(updates: List<RoomListEntriesUpdate>) {
updateRoomSummaries {
updateRoomSummaries(updates) {
Timber.v("Update rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}")
updates.forEach { update ->
applyUpdate(update)
}
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
val duplicates = groupingBy { it.roomId }.eachCount().filter { it.value > 1 }
if (duplicates.isNotEmpty()) {
Timber.e("Found duplicates in room summaries after a list update from the SDK: $duplicates. Updates: $updates")
}
}
}
suspend fun rebuildRoomSummaries() {
updateRoomSummaries {
updateRoomSummaries(emptyList()) {
forEachIndexed { i, summary ->
val result = buildRoomSummaryForIdentifier(summary.roomId.value)
if (result != null) {
@ -112,12 +109,32 @@ class RoomSummaryListProcessor(
}
}
private suspend fun updateRoomSummaries(block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(coroutineContext) {
private suspend fun updateRoomSummaries(updates: List<RoomListEntriesUpdate>, block: suspend MutableList<RoomSummary>.() -> Unit) = withContext(
coroutineContext
) {
mutex.withLock {
val current = roomSummaries.replayCache.lastOrNull()
val mutableRoomSummaries = current.orEmpty().toMutableList()
block(mutableRoomSummaries)
roomSummaries.emit(mutableRoomSummaries)
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
val uniqueRooms = mutableRoomSummaries.distinctBy { it.roomId }
if (uniqueRooms.size != mutableRoomSummaries.size) {
val duplicates = mutableRoomSummaries.groupingBy { it.roomId }.eachCount().filter { it.value > 1 }
if (duplicates.isNotEmpty()) {
analyticsService.trackError(
IllegalStateException(
"Found duplicates in room summaries after a list update from the SDK: $duplicates. " +
"Updates: ${updates.description()}"
)
)
}
}
roomSummaries.emit(uniqueRooms)
}
}
}
private fun List<RoomListEntriesUpdate>.description(): String = joinToString { it.describe() }

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -32,6 +33,7 @@ class RustSpaceRoomList(
private val innerProvider: suspend () -> InnerSpaceRoomList,
private val coroutineScope: CoroutineScope,
spaceRoomMapper: SpaceRoomMapper,
private val analyticsService: AnalyticsService,
) : SpaceRoomList {
private val innerCompletable = CompletableDeferred<InnerSpaceRoomList>()
@ -43,7 +45,8 @@ class RustSpaceRoomList(
MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false))
private val spaceListUpdateProcessor = SpaceListUpdateProcessor(
spaceRoomsFlow = spaceRoomsFlow,
mapper = spaceRoomMapper
mapper = spaceRoomMapper,
analyticsService = analyticsService,
)
init {

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
@ -41,23 +42,40 @@ class RustSpaceService(
private val sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
private val roomMembershipObserver: RoomMembershipObserver,
private val analyticsService: AnalyticsService,
) : SpaceService {
private val spaceRoomMapper = SpaceRoomMapper()
override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
private val spaceListUpdateProcessor = SpaceListUpdateProcessor(
spaceRoomsFlow = spaceRoomsFlow,
mapper = spaceRoomMapper
mapper = spaceRoomMapper,
analyticsService = analyticsService,
)
override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.topLevelJoinedSpaces()
.map {
it.let(spaceRoomMapper::map)
}
innerSpaceService
.topLevelJoinedSpaces()
.map(spaceRoomMapper::map)
}
}
override suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService
.joinedParentsOfChild(spaceId.value)
.map(spaceRoomMapper::map)
}
}
override suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom? = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.getSpaceRoom(spaceId.value)?.let { spaceRoom ->
spaceRoomMapper.map(spaceRoom)
}
}.getOrNull()
}
override fun spaceRoomList(id: RoomId): SpaceRoomList {
val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this")
return RustSpaceRoomList(
@ -65,6 +83,7 @@ class RustSpaceService(
innerProvider = { innerSpaceService.spaceRoomList(id.value) },
coroutineScope = childCoroutineScope,
spaceRoomMapper = spaceRoomMapper,
analyticsService = analyticsService,
)
}

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.impl.spaces
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
@ -19,17 +20,18 @@ import timber.log.Timber
internal class SpaceListUpdateProcessor(
private val spaceRoomsFlow: MutableSharedFlow<List<SpaceRoom>>,
private val mapper: SpaceRoomMapper,
private val analyticsService: AnalyticsService,
) {
private val mutex = Mutex()
suspend fun postUpdates(updates: List<SpaceListUpdate>) {
Timber.v("Update space rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}")
updateSpaceRooms {
updateSpaceRooms(updates) {
updates.forEach { update -> applyUpdate(update) }
}
}
private suspend fun updateSpaceRooms(block: MutableList<SpaceRoom>.() -> Unit) =
private suspend fun updateSpaceRooms(updates: List<SpaceListUpdate>, block: MutableList<SpaceRoom>.() -> Unit) =
mutex.withLock {
val spaceRooms = if (spaceRoomsFlow.replayCache.isNotEmpty()) {
spaceRoomsFlow.first().toMutableList()
@ -37,7 +39,17 @@ internal class SpaceListUpdateProcessor(
mutableListOf()
}
block(spaceRooms)
spaceRoomsFlow.emit(spaceRooms)
val uniqueRooms = spaceRooms.distinctBy { it.roomId }
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
if (spaceRooms.size != uniqueRooms.size) {
val duplicateKeys = spaceRooms.groupBy { it.roomId }.filter { it.value.size > 1 }.keys
analyticsService.trackError(
IllegalStateException("Found duplicate keys in space rooms list ($duplicateKeys) after SDK updates: ${updates.description()}")
)
}
spaceRoomsFlow.emit(uniqueRooms)
}
private fun MutableList<SpaceRoom>.applyUpdate(update: SpaceListUpdate) {
@ -83,3 +95,19 @@ internal class SpaceListUpdateProcessor(
}
}
}
private fun List<SpaceListUpdate>.description(): String = joinToString { it.description() }
private fun SpaceListUpdate.description(): String = when (this) {
is SpaceListUpdate.Append -> "Append(${values.map { it.roomId }})"
SpaceListUpdate.Clear -> "Clear"
is SpaceListUpdate.Insert -> "Insert($index, ${value.roomId})"
SpaceListUpdate.PopBack -> "PopBack"
SpaceListUpdate.PopFront -> "PopFront"
is SpaceListUpdate.PushBack -> "PushBack(${value.roomId})"
is SpaceListUpdate.PushFront -> "PushFront(${value.roomId})"
is SpaceListUpdate.Remove -> "Remove($index)"
is SpaceListUpdate.Reset -> "Reset(${values.map { it.roomId }})"
is SpaceListUpdate.Set -> "Set($index, ${value.roomId})"
is SpaceListUpdate.Truncate -> "Truncate($length)"
}

View file

@ -16,7 +16,9 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListSe
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_ROOM_ID_4
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@ -33,11 +35,10 @@ class RoomSummaryListProcessorTest {
summaries.value = listOf(aRoomSummary())
val processor = createProcessor()
val newEntry = aRustRoom(A_ROOM_ID_2)
processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(newEntry, newEntry, newEntry))))
processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(aRustRoom(A_ROOM_ID_2), aRustRoom(A_ROOM_ID_3), aRustRoom(A_ROOM_ID_4)))))
assertThat(summaries.value.count()).isEqualTo(4)
assertThat(summaries.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue()
assertThat(summaries.value.subList(1, 4).map { it.roomId }).isEqualTo(listOf(A_ROOM_ID_2, A_ROOM_ID_3, A_ROOM_ID_4))
}
@Test
@ -182,5 +183,6 @@ class RoomSummaryListProcessorTest {
FakeFfiRoomListService(),
coroutineContext = StandardTestDispatcher(testScheduler),
roomSummaryFactory = RoomSummaryFactory(),
analyticsService = FakeAnalyticsService(),
)
}

View file

@ -14,7 +14,9 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoo
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3
import io.element.android.libraries.matrix.test.A_ROOM_ID_4
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@ -29,11 +31,14 @@ class RoomSummaryListProcessorTest {
spaceRoomsFlow.value = listOf(aSpaceRoom())
val processor = createProcessor()
val newEntry = aRustSpaceRoom(roomId = A_ROOM_ID_2)
processor.postUpdates(listOf(SpaceListUpdate.Append(listOf(newEntry, newEntry, newEntry))))
processor.postUpdates(
listOf(
SpaceListUpdate.Append(listOf(aRustSpaceRoom(roomId = A_ROOM_ID_2), aRustSpaceRoom(roomId = A_ROOM_ID_3), aRustSpaceRoom(roomId = A_ROOM_ID_4)))
)
)
assertThat(spaceRoomsFlow.value.count()).isEqualTo(4)
assertThat(spaceRoomsFlow.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue()
assertThat(spaceRoomsFlow.value.subList(1, 4).map { it.roomId }).isEqualTo(listOf(A_ROOM_ID_2, A_ROOM_ID_3, A_ROOM_ID_4))
}
@Test
@ -186,5 +191,6 @@ class RoomSummaryListProcessorTest {
) = SpaceListUpdateProcessor(
spaceRoomsFlow = spaceRoomsFlow,
mapper = SpaceRoomMapper(),
analyticsService = FakeAnalyticsService(),
)
}

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoo
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSpaceRoomList
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@ -97,6 +98,7 @@ class RustSpaceRoomListTest {
innerProvider = innerProvider,
coroutineScope = backgroundScope,
spaceRoomMapper = spaceRoomMapper,
analyticsService = FakeAnalyticsService(),
)
}
}

View file

@ -46,6 +46,7 @@ val A_SPACE_ID_2 = SpaceId("!aSpaceId2:domain")
val A_ROOM_ID = RoomId("!aRoomId:domain")
val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
val A_ROOM_ID_3 = RoomId("!aRoomId3:domain")
val A_ROOM_ID_4 = RoomId("!aRoomId4:domain")
val A_THREAD_ID = ThreadId("\$aThreadId")
val A_THREAD_ID_2 = ThreadId("\$aThreadId2")
val AN_EVENT_ID = EventId("\$anEventId")

View file

@ -24,6 +24,8 @@ class FakeSpaceService(
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result<Unit> = { _, _ -> lambdaError() },
private val joinedParentsResult: (RoomId) -> Result<List<SpaceRoom>> = { lambdaError() },
private val getSpaceRoomResult: (RoomId) -> SpaceRoom? = { lambdaError() },
) : SpaceService {
private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
@ -37,6 +39,14 @@ class FakeSpaceService(
return joinedSpacesResult()
}
override suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>> {
return joinedParentsResult(spaceId)
}
override suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom? {
return getSpaceRoomResult(spaceId)
}
override fun spaceRoomList(id: RoomId): SpaceRoomList {
return spaceRoomListResult(id)
}

View file

@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.ui.media
import coil3.ImageLoader
import coil3.fetch.Fetcher
import coil3.request.Options
import coil3.toUri
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
@ -21,10 +22,19 @@ internal class AvatarDataFetcherFactory(
data: AvatarData,
options: Options,
imageLoader: ImageLoader
): Fetcher {
return CoilMediaFetcher(
mediaLoader = matrixMediaLoader,
mediaData = data.toMediaRequestData(),
)
): Fetcher? {
return when {
data.url == null -> null
data.url?.startsWith("mxc") == true -> CoilMediaFetcher(
mediaLoader = matrixMediaLoader,
mediaData = data.toMediaRequestData(),
)
else -> {
// If the URL does not use the mxc scheme, it might be a local one using `content://`, try using a fallback fetcher
data.url?.toUri()?.let { uri ->
imageLoader.components.newFetcher(uri, options, imageLoader)
}?.first
}
}
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import android.graphics.Bitmap
import coil3.ComponentRegistry
import coil3.ImageLoader
import coil3.asImage
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.Options
import coil3.request.SuccessResult
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.mockk.mockk
import org.junit.Test
class AvatarDataFetcherFactoryTest {
@Test
fun `create - with mxc returns CoilMediaFetcher`() {
val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader())
val fetcher = factory.create(anAvatarData(url = "mxc://test"), Options(mockk()), imageLoader = FakeImageLoader())
assertThat(fetcher).isInstanceOf(CoilMediaFetcher::class.java)
}
@Test
fun `create - with http or https returns null, which means fallback default fetcher will be used`() {
val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader())
val fetcherHttp = factory.create(anAvatarData(url = "http://test"), Options(mockk()), imageLoader = FakeImageLoader())
assertThat(fetcherHttp).isNull()
val fetcherHttps = factory.create(anAvatarData(url = "https://test"), Options(mockk()), imageLoader = FakeImageLoader())
assertThat(fetcherHttps).isNull()
}
@Test
fun `create - with content scheme returns null, which means fallback default fetcher will be used`() {
val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader())
val fetcher = factory.create(anAvatarData(url = "content://test"), Options(mockk()), imageLoader = FakeImageLoader())
assertThat(fetcher).isNull()
}
}
private class FakeImageLoader : ImageLoader {
override val defaults: ImageRequest.Defaults = ImageRequest.Defaults.DEFAULT
override val components: ComponentRegistry = ComponentRegistry.Builder().build()
override val memoryCache: MemoryCache? = null
override val diskCache: DiskCache? = null
override fun enqueue(request: ImageRequest): Disposable {
return mockk()
}
override suspend fun execute(request: ImageRequest): ImageResult {
return SuccessResult(
image = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8).asImage(),
request = request,
)
}
override fun shutdown() {}
override fun newBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(mockk())
}
}

View file

@ -0,0 +1,436 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
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.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Avatar picker view, based on https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=5918-97417&t=JYDQysgjS33AZb74-4
*
* It takes a [state], which can be [AvatarPickerState.Pick] for displaying the 'pick avatar' button, or [AvatarPickerState.Selected] when an avatar has
* already been selected.
*
* Note: this function contains lots of 'magic numbers', but those are just the fractions used to scale the different dimensions based on the Figma design.
*/
@Composable
fun AvatarPickerView(
state: AvatarPickerState,
modifier: Modifier = Modifier,
onClick: (() -> Unit) = {},
onClickLabel: String? = stringResource(CommonStrings.a11y_edit_avatar),
enabled: Boolean = true,
) {
val a11yAvatar = stringResource(CommonStrings.a11y_avatar)
val clickableModifier = Modifier.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
onClickLabel = onClickLabel,
onClick = onClick,
indication = ripple(bounded = false),
)
.testTag(TestTags.editAvatar)
.clearAndSetSemantics {
contentDescription = a11yAvatar
}
val layoutDirection = LocalLayoutDirection.current
fun eraseBackgroundModifier(
parentWidth: Dp,
editIconRadius: Dp,
) = Modifier
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
parentWidth.toPx() - editIconRadius.toPx() * 0.48f
} else {
editIconRadius.toPx() * 0.48f
},
y = size.height - editIconRadius.toPx(),
),
radius = editIconRadius.toPx() * 1.2f,
blendMode = BlendMode.Clear,
)
}
when (state) {
is AvatarPickerState.Pick -> {
PickButton(
buttonSize = state.buttonSize,
iconSize = state.iconSize,
iconId = state.iconId,
modifier = modifier.padding(state.externalPadding).then(clickableModifier),
)
}
is AvatarPickerState.Selected -> {
Box(modifier = modifier) {
Avatar(
avatarData = state.avatarData,
avatarType = state.type,
modifier = clickableModifier.then(eraseBackgroundModifier(state.avatarData.size.dp, state.avatarData.size.dp * 0.225f)),
)
OverlayEditButton(editButtonSize = state.avatarData.size.dp * 0.44f)
}
}
}
}
@Composable
private fun PickButton(
buttonSize: Dp,
iconSize: Dp,
@DrawableRes iconId: Int,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(buttonSize)
.clip(CircleShape)
.border(BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), shape = CircleShape)
) {
Icon(
resourceId = iconId,
contentDescription = null,
modifier = Modifier
.align(Alignment.Center)
.size(iconSize),
tint = ElementTheme.colors.iconPrimary,
)
}
}
@Composable
private fun BoxScope.OverlayEditButton(editButtonSize: Dp) {
Box(
modifier = Modifier.align(Alignment.BottomEnd)
.size(editButtonSize)
.offset(x = editButtonSize * 0.266f)
.clip(CircleShape)
.background(ElementTheme.colors.bgCanvasDefault)
.border(BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(editButtonSize * 0.66f),
imageVector = CompoundIcons.Edit(),
contentDescription = null,
)
}
}
@Immutable
sealed interface AvatarPickerState {
data class Pick(
val buttonSize: Dp,
val iconSize: Dp = buttonSize / 2,
val externalPadding: PaddingValues = PaddingValues.Zero,
@DrawableRes val iconId: Int = CompoundDrawables.ic_compound_take_photo,
) : AvatarPickerState
data class Selected(
val avatarData: AvatarData,
val type: AvatarType,
) : AvatarPickerState
}
@PreviewsDayNight
@Composable
internal fun AvatarPickerViewPreview() = ElementPreview {
PreviewContent()
}
@PreviewsDayNight
@Composable
internal fun AvatarPickerViewRtlPreview() = CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
ElementPreview { PreviewContent() }
}
@PreviewsDayNight
@Composable
internal fun AvatarPickerSizesPreview() = ElementPreview {
Column {
Row {
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 24.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 32.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 64.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 96.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
}
Row {
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
}
Row {
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(6.dp)
)
}
}
}
@Composable
private fun PreviewContent() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Pick image")
AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
HorizontalDivider()
Text("User avatar")
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", null, size = AvatarSize.EditRoomDetails),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("@user:example.com", "User", "mxc://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.User
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
}
HorizontalDivider()
Text("Room avatar")
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Room", null, size = AvatarSize.EditRoomDetails),
type = AvatarType.Room()
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Room", "content://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.Room()
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Room", "mxc://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.Room()
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
}
HorizontalDivider()
Text("Space avatar")
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Space", null, size = AvatarSize.EditRoomDetails),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Space", "content://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
avatarData = AvatarData("!room:example.com", "Space", "mxc://test", size = AvatarSize.EditRoomDetails),
type = AvatarType.Space()
),
onClick = {},
modifier = Modifier.padding(10.dp)
)
}
}
}
}

View file

@ -1,157 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun EditableAvatarView(
matrixId: String,
displayName: String?,
avatarUrl: String?,
avatarSize: AvatarSize,
avatarType: AvatarType,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val a11yAvatar = stringResource(CommonStrings.a11y_avatar)
val editIconRadius = 15.dp
val parentHeight = avatarSize.dp
val parentWidth = avatarSize.dp + editIconRadius / 2f
Box(
modifier = modifier
.wrapContentSize()
.size(height = parentHeight, width = parentWidth)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
onClickLabel = stringResource(CommonStrings.a11y_edit_avatar),
onClick = onAvatarClick,
indication = ripple(bounded = false),
)
.testTag(TestTags.editAvatar)
.clearAndSetSemantics {
contentDescription = a11yAvatar
},
) {
Box(
modifier = Modifier
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = parentWidth.toPx() - editIconRadius.toPx(),
y = size.height - editIconRadius.toPx(),
),
radius = (editIconRadius + 4.dp).toPx(),
blendMode = BlendMode.Clear,
)
}
) {
when {
avatarUrl == null || avatarUrl.startsWith("mxc://") -> {
Avatar(
avatarData = AvatarData(
id = matrixId,
name = displayName,
url = avatarUrl,
size = avatarSize,
),
avatarType = avatarType,
)
}
else -> {
UnsavedAvatar(
avatarUri = avatarUrl,
avatarSize = avatarSize,
avatarType = avatarType,
)
}
}
}
Icon(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(editIconRadius * 2)
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
.padding(6.dp),
imageVector = CompoundIcons.Edit(),
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun EditableAvatarViewPreview(
@PreviewParameter(EditableAvatarViewUriProvider::class) uri: String?
) = ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) {
EditableAvatarView(
matrixId = "id",
displayName = "Room",
avatarUrl = uri,
avatarSize = AvatarSize.RoomDetailsHeader,
avatarType = AvatarType.User,
onAvatarClick = {},
)
}
open class EditableAvatarViewUriProvider : PreviewParameterProvider<String?> {
override val values: Sequence<String?>
get() = sequenceOf(
null,
"mxc://matrix.org/123456",
"https://example.com/avatar.jpg",
)
}

View file

@ -1,93 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.avatarShape
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
/**
* An avatar that the user has selected, but which has not yet been uploaded to Matrix.
*
* The image is loaded from a local resource instead of from a MXC URI.
*/
@Composable
fun UnsavedAvatar(
avatarUri: String?,
avatarSize: AvatarSize,
avatarType: AvatarType,
modifier: Modifier = Modifier,
) {
val commonModifier = modifier
.size(avatarSize.dp)
.clip(avatarType.avatarShape(avatarSize.dp))
if (avatarUri != null) {
val context = LocalContext.current
val model = ImageRequest.Builder(context)
.data(avatarUri)
.build()
AsyncImage(
modifier = commonModifier,
model = model,
placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant),
contentScale = ContentScale.Crop,
contentDescription = null,
)
} else {
Box(modifier = commonModifier.background(ElementTheme.colors.temporaryColorBgSpecial)) {
Icon(
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = null,
modifier = Modifier
.align(Alignment.Center)
.size(avatarSize.dp * 4 / 7),
tint = ElementTheme.colors.iconSecondary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun UnsavedAvatarPreview() = ElementPreview {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.User)
UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.User)
UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.Space())
UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.Space())
}
}

View file

@ -27,7 +27,7 @@ fun RoomAddressField(
homeserverName: String,
addressValidity: RoomAddressValidity,
onAddressChange: (String) -> Unit,
label: String,
label: String?,
supportingText: String,
modifier: Modifier = Modifier,
) {

View file

@ -42,6 +42,8 @@
<string name="notification_room_invite_body_with_sender">"%1$s vás pozval(a) do místnosti"</string>
<string name="notification_sender_me">"Já"</string>
<string name="notification_sender_mention_reply">"%1$s zmínil(a) nebo odpověděl(a)"</string>
<string name="notification_space_invite_body">"Pozvali vás do prostoru"</string>
<string name="notification_space_invite_body_with_sender">"%1$s vás pozvali do prostoru"</string>
<string name="notification_test_push_notification_content">"Prohlížíte si oznámení! Klikněte na mě!"</string>
<string name="notification_thread_in_room">"Vlákno v %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
@ -99,5 +101,5 @@
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Chyba, nelze otestovat push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Chyba, časový limit čekání na push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Push zpětná smyčka trvala %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Otestovat push zpětnou smyčku"</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Otestovat push pomocí zpětného volání"</string>
</resources>

View file

@ -13,6 +13,7 @@
<item quantity="one">"%d Mitteilung"</item>
<item quantity="other">"%d Mitteilungen"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"Der Dienst für UnifiedPush Benachrichtigungen konnte nicht registriert werden. Daher können aktuell keine Push-Benachrichtigungen erhalten werden. Bitte überprüfe die Einstellungen der Benachrichtigungen in der App und den Status des Push-Dienstes."</string>
<string name="notification_fallback_content">"Du hast neue Nachrichten."</string>
<string name="notification_incoming_call">"Eingehender Anruf"</string>
<string name="notification_inline_reply_failed">"** Fehler beim Senden - bitte Chat öffnen"</string>
@ -37,6 +38,8 @@
<string name="notification_room_invite_body_with_sender">"%1$s hat dich eingeladen, dem Chat beizutreten"</string>
<string name="notification_sender_me">"Ich"</string>
<string name="notification_sender_mention_reply">"%1$s hat Dich erwähnt oder geantwortet"</string>
<string name="notification_space_invite_body">"Einladung zum Space"</string>
<string name="notification_space_invite_body_with_sender">"%1$s hat dich eingeladen, dem Space beizutreten"</string>
<string name="notification_test_push_notification_content">"Du siehst dir die Benachrichtigung an! Klicke hier!"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>

View file

@ -27,9 +27,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -39,9 +39,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.hideFromAccessibility
@ -61,6 +62,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconColorButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
@ -70,11 +72,11 @@ import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.textcomposer.components.SendButton
import io.element.android.libraries.textcomposer.components.SendButtonIcon
import io.element.android.libraries.textcomposer.components.TextFormatting
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton
import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButtonIcon
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton
import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButtonIcon
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
@ -123,9 +125,6 @@ fun TextComposer(
is TextEditorState.Markdown -> state.state.text.value()
is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown
}
val onSendClick = {
onSendMessage()
}
val onPlayVoiceMessageClick = {
onVoicePlayerEvent(VoiceMessagePlayerEvent.Play)
@ -143,26 +142,6 @@ fun TextComposer(
.fillMaxSize()
.height(IntrinsicSize.Min)
val composerOptionsButton: @Composable () -> Unit = remember(composerMode) {
@Composable {
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(9.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(16.dp))
}
else -> {
IconColorButton(
onClick = onAddAttachment,
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
)
}
}
}
}
val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
} else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) {
@ -234,55 +213,137 @@ fun TextComposer(
}
}
val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment
val sendButton = @Composable {
SendButton(
canSendMessage = canSendMessage,
onClick = onSendClick,
composerMode = composerMode,
)
}
val recordVoiceButton = @Composable {
VoiceMessageRecorderButton(
isRecording = voiceMessageState is VoiceMessageState.Recording,
onEvent = onVoiceRecorderEvent,
)
}
val sendVoiceButton = @Composable {
SendButton(
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
onClick = onSendVoiceMessage,
composerMode = composerMode,
)
}
val uploadVoiceProgress = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
val canSendTextMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment
val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let {
@Composable { TextFormatting(state = it.richTextEditorState) }
}
val sendOrRecordButton = when {
!canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> recordVoiceButton
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> uploadVoiceProgress
false -> sendVoiceButton
}
}
else -> sendButton
val hapticFeedback = LocalHapticFeedback.current
fun performHapticFeedback() {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
val endButtonA11y = endButtonA11y(
composerMode = composerMode,
voiceMessageState = voiceMessageState,
canSendMessage = canSendMessage,
)
@Composable
fun rememberEndButtonParams() = remember(
composerMode.isEditing,
voiceMessageState.endButtonKey(),
canSendTextMessage,
) {
when {
!canSendTextMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.a11y_voice_message_record,
endButtonClick = {
performHapticFeedback()
onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Start)
},
endButtonContent = @Composable {
VoiceMessageRecorderButtonIcon(
isRecording = false,
)
}
)
is VoiceMessageState.Recording -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.a11y_voice_message_stop_recording,
endButtonClick = {
performHapticFeedback()
onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Stop)
},
endButtonContent = @Composable {
VoiceMessageRecorderButtonIcon(
isRecording = true,
)
}
)
is VoiceMessageState.Preview -> if (voiceMessageState.isSending) {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.common_sending,
endButtonClick = {},
endButtonContent = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
)
} else {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_voice_message,
endButtonClick = {
onSendVoiceMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = composerMode.isEditing,
)
},
)
}
}
composerMode.isEditing -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
endButtonClick = {
onSendMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = true,
)
},
)
else -> EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_message,
endButtonClick = {
onSendMessage()
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = true,
isEditing = false,
)
},
)
}
}
@Composable
fun rememberEndButtonParamsFormatting() = remember(composerMode.isEditing, canSendTextMessage) {
if (composerMode.isEditing) {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_edited_message,
endButtonClick = {
if (canSendTextMessage) {
onSendMessage()
}
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = canSendTextMessage,
isEditing = true,
)
},
)
} else {
EndButtonParams(
endButtonContentDescriptionResId = CommonStrings.action_send_message,
endButtonClick = {
if (canSendTextMessage) {
onSendMessage()
}
},
endButtonContent = @Composable {
SendButtonIcon(
canSendMessage = canSendTextMessage,
isEditing = false,
)
},
)
}
}
val voiceRecording = @Composable {
when (voiceMessageState) {
@ -307,17 +368,8 @@ fun TextComposer(
}
}
val voiceDeleteButton = @Composable {
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButton(enabled = true, onClick = { onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) })
else -> {}
}
}
if (showTextFormatting && textFormattingOptions != null) {
val endButtonParams = rememberEndButtonParamsFormatting()
TextFormattingLayout(
modifier = layoutModifier,
isRoomEncrypted = state.isRoomEncrypted,
@ -330,20 +382,21 @@ fun TextComposer(
)
},
textFormatting = textFormattingOptions,
endButtonA11y = endButtonA11y,
sendButton = sendButton,
endButtonParams = endButtonParams,
)
} else {
val endButtonParams = rememberEndButtonParams()
StandardLayout(
composerMode = composerMode,
voiceMessageState = voiceMessageState,
isRoomEncrypted = state.isRoomEncrypted,
modifier = layoutModifier,
composerOptionsButton = composerOptionsButton,
textInput = textInput,
endButton = sendOrRecordButton,
endButtonA11y = endButtonA11y,
endButtonParams = endButtonParams,
voiceRecording = voiceRecording,
voiceDeleteButton = voiceDeleteButton,
onAddAttachment = onAddAttachment,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onVoiceRecorderEvent = onVoiceRecorderEvent,
)
}
@ -367,49 +420,23 @@ fun TextComposer(
}
}
@ReadOnlyComposable
@Composable
private fun endButtonA11y(
composerMode: MessageComposerMode,
voiceMessageState: VoiceMessageState,
canSendMessage: Boolean,
): (SemanticsPropertyReceiver) -> Unit {
val a11ySendButtonDescription = stringResource(
id = when {
!canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> if (voiceMessageState is VoiceMessageState.Recording) {
CommonStrings.a11y_voice_message_stop_recording
} else {
CommonStrings.a11y_voice_message_record
}
is VoiceMessageState.Preview -> when (voiceMessageState.isSending) {
true -> CommonStrings.common_sending
false -> CommonStrings.action_send_voice_message
}
}
composerMode.isEditing -> CommonStrings.action_send_edited_message
else -> CommonStrings.action_send_message
}
)
val endButtonA11y: (SemanticsPropertyReceiver.() -> Unit) = {
contentDescription = a11ySendButtonDescription
onClick(null, null)
}
return endButtonA11y
}
private data class EndButtonParams(
val endButtonContentDescriptionResId: Int,
val endButtonClick: () -> Unit,
val endButtonContent: @Composable () -> Unit,
)
@Composable
private fun StandardLayout(
composerMode: MessageComposerMode,
voiceMessageState: VoiceMessageState,
isRoomEncrypted: Boolean?,
textInput: @Composable () -> Unit,
composerOptionsButton: @Composable () -> Unit,
voiceRecording: @Composable () -> Unit,
voiceDeleteButton: @Composable () -> Unit,
endButton: @Composable () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
endButtonParams: EndButtonParams,
onAddAttachment: () -> Unit,
onDeleteVoiceMessage: () -> Unit,
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@ -419,50 +446,80 @@ private fun StandardLayout(
Spacer(Modifier.height(4.dp))
}
Row(verticalAlignment = Alignment.Bottom) {
if (voiceMessageState !is VoiceMessageState.Idle) {
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) {
Box(
when (composerMode) {
is MessageComposerMode.Attachment -> {
Spacer(modifier = Modifier.width(12.dp))
}
is MessageComposerMode.EditCaption -> {
Spacer(modifier = Modifier.width(19.dp))
}
else -> {
val endPadding = if (voiceMessageState is VoiceMessageState.Idle) 0.dp else 3.dp
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
IconButton(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
.padding(top = 5.dp, bottom = 5.dp, start = 3.dp, end = endPadding)
.size(48.dp),
contentAlignment = Alignment.Center,
onClick = {
if (voiceMessageState is VoiceMessageState.Idle) {
onAddAttachment()
} else {
when (voiceMessageState) {
is VoiceMessageState.Preview -> if (!voiceMessageState.isSending) {
onDeleteVoiceMessage()
}
is VoiceMessageState.Recording ->
onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel)
}
}
},
) {
voiceDeleteButton()
if (voiceMessageState is VoiceMessageState.Idle) {
Icon(
modifier = Modifier
.clip(CircleShape)
.size(30.dp)
.background(ElementTheme.colors.iconPrimary)
.padding(3.dp),
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment),
tint = ElementTheme.colors.iconOnSolidPrimary
)
} else {
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessageDeleteButtonIcon(enabled = !voiceMessageState.isSending)
is VoiceMessageState.Recording ->
VoiceMessageDeleteButtonIcon(enabled = true)
}
}
}
} else {
Spacer(modifier = Modifier.width(16.dp))
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
voiceRecording()
}
} else {
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, start = 3.dp)
) {
composerOptionsButton()
}
Box(
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
textInput()
}
}
Box(
Modifier
modifier = Modifier
.padding(bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
if (voiceMessageState is VoiceMessageState.Idle) {
textInput()
} else {
voiceRecording()
}
}
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
val endButtonContentDescription = stringResource(endButtonParams.endButtonContentDescriptionResId)
IconButton(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp)
.clearAndSetSemantics(endButtonA11y),
contentAlignment = Alignment.Center,
) {
endButton()
}
.clearAndSetSemantics {
contentDescription = endButtonContentDescription
onClick(null, null)
},
onClick = endButtonParams.endButtonClick,
content = endButtonParams.endButtonContent,
)
}
}
}
@ -495,8 +552,7 @@ private fun TextFormattingLayout(
textInput: @Composable () -> Unit,
dismissTextFormattingButton: @Composable () -> Unit,
textFormatting: @Composable () -> Unit,
sendButton: @Composable () -> Unit,
endButtonA11y: (SemanticsPropertyReceiver.() -> Unit),
endButtonParams: EndButtonParams,
modifier: Modifier = Modifier
) {
Column(
@ -527,16 +583,22 @@ private fun TextFormattingLayout(
Box(modifier = Modifier.weight(1f)) {
textFormatting()
}
Box(
// To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled.
val endButtonContentDescription = stringResource(endButtonParams.endButtonContentDescriptionResId)
IconButton(
modifier = Modifier
.padding(
start = 14.dp,
end = 6.dp,
)
.clearAndSetSemantics(endButtonA11y)
) {
sendButton()
}
.size(48.dp)
.clearAndSetSemantics {
contentDescription = endButtonContentDescription
onClick(null, null)
},
onClick = endButtonParams.endButtonClick,
content = endButtonParams.endButtonContent,
)
}
}
}
@ -596,6 +658,12 @@ private fun TextInputBox(
}
}
private fun VoiceMessageState.endButtonKey() = when (this) {
is VoiceMessageState.Idle -> "Idle"
is VoiceMessageState.Preview -> "Preview_$isSending"
is VoiceMessageState.Recording -> "Recording"
}
private fun aTextEditorStateMarkdownList(isRoomEncrypted: Boolean? = null) = persistentListOf(
aTextEditorStateMarkdown(initialText = "", initialFocus = true, isRoomEncrypted = isRoomEncrypted),
aTextEditorStateMarkdown(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted),

View file

@ -29,9 +29,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.textcomposer.model.MessageComposerMode
/**
* Send button for the message composer.
@ -39,50 +36,42 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
* Temporary Figma : https://www.figma.com/design/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?node-id=2274-39944&m=dev
*/
@Composable
internal fun SendButton(
internal fun SendButtonIcon(
canSendMessage: Boolean,
onClick: () -> Unit,
composerMode: MessageComposerMode,
isEditing: Boolean,
modifier: Modifier = Modifier,
) {
IconButton(
val iconVector = when {
isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
isEditing -> 0.dp
else -> 2.dp
}
Box(
modifier = modifier
.size(48.dp),
onClick = onClick,
enabled = canSendMessage,
.clip(CircleShape)
.size(36.dp)
.buttonBackgroundModifier(canSendMessage)
) {
val iconVector = when {
composerMode.isEditing -> CompoundIcons.Check()
else -> CompoundIcons.SendSolid()
}
val iconStartPadding = when {
composerMode.isEditing -> 0.dp
else -> 2.dp
}
Box(
Icon(
modifier = Modifier
.clip(CircleShape)
.size(36.dp)
.buttonBackgroundModifier(canSendMessage)
) {
Icon(
modifier = Modifier
.padding(start = iconStartPadding)
.align(Alignment.Center),
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconPrimary
}
.padding(start = iconStartPadding)
.align(Alignment.Center),
imageVector = iconVector,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = if (canSendMessage) {
if (ElementTheme.colors.isLight) {
ElementTheme.colors.iconOnSolidPrimary
} else {
ElementTheme.colors.iconQuaternary
ElementTheme.colors.iconPrimary
}
)
}
} else {
ElementTheme.colors.iconQuaternary
}
)
}
}
@ -113,13 +102,19 @@ private fun Modifier.buttonBackgroundModifier(
@PreviewsDayNight
@Composable
internal fun SendButtonPreview() = ElementPreview {
val normalMode = MessageComposerMode.Normal
val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "")
internal fun SendButtonIconPreview() = ElementPreview {
Row {
SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode)
SendButton(canSendMessage = true, onClick = {}, composerMode = editMode)
SendButton(canSendMessage = false, onClick = {}, composerMode = editMode)
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = true, isEditing = false)
}
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = false, isEditing = false)
}
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = true, isEditing = true)
}
IconButton(onClick = {}) {
SendButtonIcon(canSendMessage = false, isEditing = true)
}
}
}

View file

@ -23,41 +23,35 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun VoiceMessageDeleteButton(
fun VoiceMessageDeleteButtonIcon(
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier
.size(48.dp),
enabled = enabled,
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = CompoundIcons.Delete(),
contentDescription = stringResource(CommonStrings.a11y_delete),
tint = if (enabled) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconDisabled
},
)
}
Icon(
modifier = modifier.size(24.dp),
imageVector = CompoundIcons.Delete(),
contentDescription = stringResource(CommonStrings.a11y_delete),
tint = if (enabled) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconDisabled
},
)
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageDeleteButtonPreview() = ElementPreview {
internal fun VoiceMessageDeleteButtonIconPreview() = ElementPreview {
Row {
VoiceMessageDeleteButton(
enabled = true,
onClick = {},
)
VoiceMessageDeleteButton(
enabled = false,
onClick = {},
)
IconButton(onClick = {}) {
VoiceMessageDeleteButtonIcon(
enabled = true,
)
}
IconButton(onClick = {}) {
VoiceMessageDeleteButtonIcon(
enabled = false,
)
}
}
}

View file

@ -67,22 +67,12 @@ internal fun VoiceMessagePreview(
.heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isPlaying) {
PlayerButton(
type = PlayerButtonType.Pause,
onClick = onPauseClick,
enabled = isInteractive,
)
} else {
PlayerButton(
type = PlayerButtonType.Play,
onClick = onPlayClick,
enabled = isInteractive
)
}
PlayerButton(
type = if (isPlaying) PlayerButtonType.Pause else PlayerButtonType.Play,
onClick = if (isPlaying) onPauseClick else onPlayClick,
enabled = isInteractive,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = time.formatShort(),
color = ElementTheme.colors.textSecondary,
@ -90,9 +80,7 @@ internal fun VoiceMessagePreview(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(12.dp))
WaveformPlaybackView(
modifier = Modifier
.weight(1f)

View file

@ -14,9 +14,8 @@ 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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@ -25,49 +24,25 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@Composable
internal fun VoiceMessageRecorderButton(
internal fun VoiceMessageRecorderButtonIcon(
isRecording: Boolean,
onEvent: (VoiceMessageRecorderEvent) -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val performHapticFeedback = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
if (isRecording) {
StopButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Stop)
}
)
StopButton(modifier)
} else {
StartButton(
modifier = modifier,
onClick = {
performHapticFeedback()
onEvent(VoiceMessageRecorderEvent.Start)
}
)
StartButton(modifier)
}
}
@Composable
private fun StartButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier.size(48.dp),
onClick = onClick,
) {
Icon(
modifier = Modifier.size(24.dp),
modifier = modifier.size(24.dp),
imageVector = CompoundIcons.MicOn(),
// Note: accessibility is managed in TextComposer.
contentDescription = null,
@ -77,41 +52,40 @@ private fun StartButton(
@Composable
private fun StopButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) = IconButton(
modifier = modifier
.size(48.dp),
onClick = onClick,
) {
Box(
Modifier
modifier
.size(36.dp)
.background(
color = ElementTheme.colors.bgActionPrimaryRest,
shape = CircleShape,
)
)
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
)
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(24.dp),
resourceId = CommonDrawables.ic_stop,
// Note: accessibility is managed in TextComposer.
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun VoiceMessageRecorderButtonPreview() = ElementPreview {
internal fun VoiceMessageRecorderButtonIconPreview() = ElementPreview {
Row {
VoiceMessageRecorderButton(
isRecording = false,
onEvent = {},
)
VoiceMessageRecorderButton(
isRecording = true,
onEvent = {},
)
IconButton(onClick = {}) {
VoiceMessageRecorderButtonIcon(
isRecording = false,
)
}
IconButton(onClick = {}) {
VoiceMessageRecorderButtonIcon(
isRecording = true,
)
}
}
}

View file

@ -114,6 +114,7 @@
<string name="action_load_more">"Načíst více"</string>
<string name="action_manage_account">"Spravovat účet"</string>
<string name="action_manage_devices">"Spravovat zařízení"</string>
<string name="action_manage_rooms">"Spravovat místnosti"</string>
<string name="action_message">"Zpráva"</string>
<string name="action_minimize">"Minimalizovat"</string>
<string name="action_next">"Další"</string>
@ -163,6 +164,7 @@
<string name="action_static_map_load">"Klepnutím načtete mapu"</string>
<string name="action_take_photo">"Vyfotit"</string>
<string name="action_tap_for_options">"Klepnutím zobrazíte možnosti"</string>
<string name="action_translate">"Přeložit"</string>
<string name="action_try_again">"Zkusit znovu"</string>
<string name="action_unpin">"Odepnout"</string>
<string name="action_view">"Zobrazit"</string>
@ -237,6 +239,7 @@ Důvod: %1$s."</string>
<string name="common_light">"Světlý"</string>
<string name="common_line_copied_to_clipboard">"Řádek zkopírován do schránky"</string>
<string name="common_link_copied_to_clipboard">"Odkaz zkopírován do schránky"</string>
<string name="common_link_new_device">"Připojit nové zařízení"</string>
<string name="common_loading">"Načítání…"</string>
<string name="common_loading_more">"Načítání dalších…"</string>
<plurals name="common_many_members">
@ -251,10 +254,12 @@ Důvod: %1$s."</string>
</plurals>
<string name="common_message">"Zpráva"</string>
<string name="common_message_actions">"Akce zprávy"</string>
<string name="common_message_failed_to_send">"Zprávu se nepodařilo odeslat"</string>
<string name="common_message_layout">"Zobrazení zpráv"</string>
<string name="common_message_removed">"Zpráva byla odstraněna"</string>
<string name="common_modern">"Moderní"</string>
<string name="common_mute">"Ztlumit"</string>
<string name="common_name">"Název"</string>
<string name="common_name_and_id">"%1$s (%2$s)"</string>
<string name="common_no_results">"Žádné výsledky"</string>
<string name="common_no_room_name">"Žádný název místnosti"</string>
@ -330,6 +335,7 @@ Důvod: %1$s."</string>
<string name="common_something_went_wrong">"Něco se nepovedlo"</string>
<string name="common_something_went_wrong_message">"Narazili jsme na problém. Zkuste to prosím znovu."</string>
<string name="common_space">"Prostor"</string>
<string name="common_space_topic_placeholder">"O čem je tento prostor?"</string>
<plurals name="common_spaces">
<item quantity="one">"%1$d prostor"</item>
<item quantity="few">"%1$d prostory"</item>
@ -375,6 +381,7 @@ Důvod: %1$s."</string>
<string name="common_waiting">"Čekání…"</string>
<string name="common_waiting_for_decryption_key">"Čekání na dešifrovací klíč"</string>
<string name="common_you">"Vy"</string>
<string name="crypto_history_visible">"Tato místnost byla nastavena tak, aby noví členové mohli číst historii. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"Identita uživatele %1$s se změnila. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Identita uživatele %1$s %2$s se změnila. %3$s"</string>
<string name="crypto_identity_change_pin_violation_new_user_id">"(%1$s)"</string>

View file

@ -112,6 +112,7 @@
<string name="action_load_more">"Indlæs mere"</string>
<string name="action_manage_account">"Administrer konto"</string>
<string name="action_manage_devices">"Administrer enheder"</string>
<string name="action_manage_rooms">"Administrer rum"</string>
<string name="action_message">"Besked"</string>
<string name="action_minimize">"Minimér"</string>
<string name="action_next">"Næste"</string>

View file

@ -56,6 +56,7 @@
<string name="a11y_your_avatar">"Dein Avatar"</string>
<string name="action_accept">"Akzeptieren"</string>
<string name="action_add_caption">"Bildunterschrift hinzufügen"</string>
<string name="action_add_existing_rooms">"Bestehende Chats hinzufügen"</string>
<string name="action_add_to_timeline">"Zum Nachrichtenverlauf hinzufügen"</string>
<string name="action_back">"Zurück"</string>
<string name="action_call">"Anruf"</string>
@ -75,6 +76,7 @@
<string name="action_copy_text">"Text kopieren"</string>
<string name="action_create">"Erstellen"</string>
<string name="action_create_a_room">"Chat erstellen"</string>
<string name="action_create_space">"Space erstellen"</string>
<string name="action_deactivate">"Deaktivieren"</string>
<string name="action_deactivate_account">"Nutzerkonto deaktivieren"</string>
<string name="action_decline">"Ablehnen"</string>
@ -95,6 +97,7 @@
<string name="action_forgot_password">"Passwort vergessen?"</string>
<string name="action_forward">"Weiterleiten"</string>
<string name="action_go_back">"Zurück"</string>
<string name="action_go_to_roles_and_permissions">"Gehe zu Rollen &amp; Berechtigungen"</string>
<string name="action_go_to_settings">"Zu den Einstellungen"</string>
<string name="action_ignore">"Ignorieren"</string>
<string name="action_invite">"Einladen"</string>
@ -111,6 +114,7 @@
<string name="action_load_more">"Mehr laden…"</string>
<string name="action_manage_account">"Konto verwalten"</string>
<string name="action_manage_devices">"Geräte verwalten"</string>
<string name="action_manage_rooms">"Chats und Gruppen konfigurieren"</string>
<string name="action_message">"Nachricht"</string>
<string name="action_minimize">"Minimieren"</string>
<string name="action_next">"Weiter"</string>
@ -160,6 +164,7 @@
<string name="action_static_map_load">"Tippe, um die Karte zu laden"</string>
<string name="action_take_photo">"Foto aufnehmen"</string>
<string name="action_tap_for_options">"Für Optionen tippen"</string>
<string name="action_translate">"Übersetzen"</string>
<string name="action_try_again">"Erneut versuchen"</string>
<string name="action_unpin">"Lösen"</string>
<string name="action_view">"Ansicht"</string>
@ -189,6 +194,7 @@
<string name="common_copied_to_clipboard">"In die Zwischenablage kopiert"</string>
<string name="common_copyright">"Copyright"</string>
<string name="common_creating_room">"Chat wird erstellt…"</string>
<string name="common_creating_space">"Space wird angelegt…"</string>
<string name="common_current_user_canceled_knock">"Anfrage abgebrochen"</string>
<string name="common_current_user_left_room">"Hat den Chat verlassen"</string>
<string name="common_current_user_left_space">"Space verlassen"</string>
@ -234,6 +240,7 @@ Grund: %1$s."</string>
<string name="common_light">"Hell"</string>
<string name="common_line_copied_to_clipboard">"Zeile in die Zwischenablage kopiert"</string>
<string name="common_link_copied_to_clipboard">"Link in die Zwischenablage kopiert"</string>
<string name="common_link_new_device">"Neues Gerät verknüpfen"</string>
<string name="common_loading">"Laden…"</string>
<string name="common_loading_more">"Mehr wird geladen…"</string>
<plurals name="common_many_members">
@ -246,10 +253,12 @@ Grund: %1$s."</string>
</plurals>
<string name="common_message">"Nachricht"</string>
<string name="common_message_actions">"Nachrichtenaktionen"</string>
<string name="common_message_failed_to_send">"Nachricht konnte nicht gesendet werden"</string>
<string name="common_message_layout">"Nachrichtenlayout"</string>
<string name="common_message_removed">"Nachricht entfernt"</string>
<string name="common_modern">"Modern"</string>
<string name="common_mute">"Stumm"</string>
<string name="common_name">"Name"</string>
<string name="common_name_and_id">"%1$s(%2$s)"</string>
<string name="common_no_results">"Keine Ergebnisse"</string>
<string name="common_no_room_name">"Kein Chat-Name"</string>
@ -295,7 +304,7 @@ Grund: %1$s."</string>
<string name="common_rich_text_editor">"Rich-Text-Editor"</string>
<string name="common_room">"Chat"</string>
<string name="common_room_name">"Chat-Name"</string>
<string name="common_room_name_placeholder">"z.B. dein Projektname"</string>
<string name="common_room_name_placeholder">"z.B. Projektname"</string>
<plurals name="common_rooms">
<item quantity="one">"%1$d Chat"</item>
<item quantity="other">"%1$d Chats"</item>
@ -324,6 +333,7 @@ Grund: %1$s."</string>
<string name="common_something_went_wrong">"Es ist ein Fehler aufgetreten."</string>
<string name="common_something_went_wrong_message">"Wir haben ein Problem festgestellt. Bitte versuch es erneut."</string>
<string name="common_space">"Space"</string>
<string name="common_space_topic_placeholder">"Worum geht es hier?"</string>
<plurals name="common_spaces">
<item quantity="one">"%1$d Space"</item>
<item quantity="other">"%1$d Spaces"</item>
@ -331,6 +341,7 @@ Grund: %1$s."</string>
<string name="common_starting_chat">"Chat wird gestartet…"</string>
<string name="common_sticker">"Sticker"</string>
<string name="common_success">"Erfolg"</string>
<string name="common_suggested">"Empfohlen"</string>
<string name="common_suggestions">"Vorschläge"</string>
<string name="common_syncing">"Synchronisieren"</string>
<string name="common_system">"System"</string>
@ -338,7 +349,7 @@ Grund: %1$s."</string>
<string name="common_third_party_notices">"Hinweise von Drittanbietern"</string>
<string name="common_thread">"Thread"</string>
<string name="common_topic">"Thema"</string>
<string name="common_topic_placeholder">"Worum geht es in diesem Chat?"</string>
<string name="common_topic_placeholder">"Worum geht is in diesem Chat?"</string>
<string name="common_unable_to_decrypt">"Entschlüsselung nicht möglich"</string>
<string name="common_unable_to_decrypt_insecure_device">"Von einem ungesicherten Gerät gesendet"</string>
<string name="common_unable_to_decrypt_no_access">"Du hast keinen Zugriff auf diese Nachricht."</string>
@ -368,6 +379,8 @@ Grund: %1$s."</string>
<string name="common_waiting">"Warten…"</string>
<string name="common_waiting_for_decryption_key">"Warte auf diese Nachricht"</string>
<string name="common_you">"Du"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) hat diese Nachricht geteilt, weil du nicht im Chat warst, als sie verschickt wurde."</string>
<string name="crypto_history_visible">"Diese Gruppe wurde so konfiguriert, dass neue Mitglieder den vergangenen Nachrichtenverlauf lesen können. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"%1$s\'s Identität has sich geändert. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"%1$s\'s %2$s Identität hat sich geändert. %3$s"</string>
<string name="crypto_identity_change_pin_violation_new_user_id">"(%1$s)"</string>
@ -464,11 +477,18 @@ Möchtest du wirklich fortfahren?"</string>
<string name="screen_share_open_google_maps">"In Google Maps öffnen"</string>
<string name="screen_share_open_osm_maps">"In OpenStreetMap öffnen"</string>
<string name="screen_share_this_location_action">"Diesen Standort teilen"</string>
<string name="screen_space_add_rooms_room_access_description">"Das Hinzufügen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""</string>
<string name="screen_space_list_description">"Von dir erstellte oder beigetretene Spaces."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_empty_state_title">"Erstelle einen Space, um Chats zu organisieren"</string>
<string name="screen_space_list_parent_space">"%1$s Space"</string>
<string name="screen_space_list_title">"Spaces"</string>
<string name="screen_space_menu_action_members">"Mitglieder anzeigen"</string>
<string name="screen_space_remove_rooms_confirmation_content">"Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\""</string>
<plurals name="screen_space_remove_rooms_confirmation_title">
<item quantity="one">"Chat aus %1$s entfernen"</item>
<item quantity="other">"%1$d chats aus %2$s entfernen"</item>
</plurals>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Nachricht nicht gesendet, weil sich die verifizierte Identität von %1$s geändert hat."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Die Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Die Nachricht wurde nicht gesendet, weil du eines oder mehrere deiner Geräte nicht verifiziert hast."</string>

View file

@ -112,6 +112,7 @@
<string name="action_load_more">"Näita veel"</string>
<string name="action_manage_account">"Halda kasutajakontot"</string>
<string name="action_manage_devices">"Halda seadmeid"</string>
<string name="action_manage_rooms">"Halda jututuba"</string>
<string name="action_message">"Saada sõnum"</string>
<string name="action_minimize">"Minimeeri"</string>
<string name="action_next">"Edasi"</string>
@ -435,11 +436,6 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="screen_create_poll_options_section_title">"Valikud"</string>
<string name="screen_create_poll_remove_accessibility_label">"Kustuta: %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Seadistused"</string>
<string name="screen_manage_authorized_spaces_header">"Kogukonnad, milles on võimalik jututoaga liituda ilma kutseta."</string>
<string name="screen_manage_authorized_spaces_title">"Halda kogukondi"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Tundmatu kogukond)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Muud kogukonnad, mille liige sa ei ole"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Sinu kogukonnad"</string>
<string name="screen_media_picker_error_failed_selection">"Meediafaili valimine ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_pinned_timeline_empty_state_description">"Siia lisamiseks vajuta sõnumil ja vali „%1$s“."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Et olulisi sõnumeid oleks lihtsam leida, tõsta nad esile"</string>

View file

@ -112,6 +112,7 @@
<string name="action_load_more">"Lataa lisää"</string>
<string name="action_manage_account">"Hallitse tiliä"</string>
<string name="action_manage_devices">"Hallitse laitteita"</string>
<string name="action_manage_rooms">"Huoneiden hallitseminen"</string>
<string name="action_message">"Lähetä viesti"</string>
<string name="action_minimize">"Pienennä"</string>
<string name="action_next">"Seuraava"</string>

View file

@ -56,6 +56,7 @@
<string name="a11y_your_avatar">"Votre avatar"</string>
<string name="action_accept">"Accepter"</string>
<string name="action_add_caption">"Ajouter une légende"</string>
<string name="action_add_existing_rooms">"Ajouter des salons existants"</string>
<string name="action_add_to_timeline">"Ajouter à la discussion"</string>
<string name="action_back">"Retour"</string>
<string name="action_call">"Appel"</string>
@ -75,6 +76,7 @@
<string name="action_copy_text">"Copier le texte"</string>
<string name="action_create">"Créer"</string>
<string name="action_create_a_room">"Créer un salon"</string>
<string name="action_create_space">"Créer un espace"</string>
<string name="action_deactivate">"Désactiver"</string>
<string name="action_deactivate_account">"Désactiver le compte"</string>
<string name="action_decline">"Refuser"</string>
@ -112,6 +114,7 @@
<string name="action_load_more">"Voir plus"</string>
<string name="action_manage_account">"Gérer le compte"</string>
<string name="action_manage_devices">"Gérez les sessions"</string>
<string name="action_manage_rooms">"Gérer les salons"</string>
<string name="action_message">"Message"</string>
<string name="action_minimize">"Minimiser"</string>
<string name="action_next">"Suivant"</string>
@ -191,6 +194,7 @@
<string name="common_copied_to_clipboard">"Copié dans le presse-papiers"</string>
<string name="common_copyright">"Droits dauteur"</string>
<string name="common_creating_room">"Création du salon…"</string>
<string name="common_creating_space">"Création de lespace…"</string>
<string name="common_current_user_canceled_knock">"Demande annulée"</string>
<string name="common_current_user_left_room">"Vous avez quitté le salon"</string>
<string name="common_current_user_left_space">"Vous avez quitté lespace"</string>
@ -337,6 +341,7 @@ Raison : %1$s."</string>
<string name="common_starting_chat">"Création de la discussion…"</string>
<string name="common_sticker">"Autocollant"</string>
<string name="common_success">"Succès"</string>
<string name="common_suggested">"Recommandé"</string>
<string name="common_suggestions">"Suggestions"</string>
<string name="common_syncing">"Synchronisation"</string>
<string name="common_system">"Système"</string>
@ -374,6 +379,8 @@ Raison : %1$s."</string>
<string name="common_waiting">"En attente…"</string>
<string name="common_waiting_for_decryption_key">"En attente de la clé de déchiffrement"</string>
<string name="common_you">"Vous"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) a partagé ce message avec vous car vous nétiez pas dans le salon lors de son envoi."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s a partagé ce message avec vous car vous nétiez pas dans le salon lors de son envoi."</string>
<string name="crypto_history_visible">"Ce salon a été configuré pour que les nouveaux membres puissent lire lhistorique. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"Lidentité de %1$s a été réinitialisée. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Lidentité de %1$s %2$s a été réinitialisée. %3$s"</string>
@ -435,11 +442,6 @@ Raison : %1$s."</string>
<string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Supprimer %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Paramètres"</string>
<string name="screen_manage_authorized_spaces_header">"Espaces où les membres peuvent rejoindre le salon sans invitation."</string>
<string name="screen_manage_authorized_spaces_title">"Gérer les espaces"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Espace inconnu)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Autres espaces dont vous nêtes pas membre"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vos espaces"</string>
<string name="screen_media_picker_error_failed_selection">"Échec de la sélection du média, veuillez réessayer."</string>
<string name="screen_pinned_timeline_empty_state_description">"Cliquez (clic long) sur un message et choisissez « %1$s » pour quil apparaisse ici."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Épinglez les messages importants pour leur donner plus de visibilité"</string>
@ -478,6 +480,7 @@ Raison : %1$s."</string>
<string name="screen_share_this_location_action">"Partager cette position"</string>
<string name="screen_space_list_description">"Espaces que vous avez créés ou rejoints."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_empty_state_title">"Créer des espaces pour organiser les salons"</string>
<string name="screen_space_list_parent_space">"Espace %1$s"</string>
<string name="screen_space_list_title">"Espaces"</string>
<string name="screen_space_menu_action_members">"Voir les membres"</string>

View file

@ -114,6 +114,7 @@
<string name="action_load_more">"Učitaj više"</string>
<string name="action_manage_account">"Upravljanje računom"</string>
<string name="action_manage_devices">"Upravljanje uređajima"</string>
<string name="action_manage_rooms">"Upravljaj sobama"</string>
<string name="action_message">"Poruka"</string>
<string name="action_minimize">"Minimiziraj"</string>
<string name="action_next">"Dalje"</string>
@ -443,11 +444,6 @@ Jeste li sigurni da želite nastaviti?"</string>
<string name="screen_create_poll_options_section_title">"Mogućnosti"</string>
<string name="screen_create_poll_remove_accessibility_label">"Ukloni %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Postavke"</string>
<string name="screen_manage_authorized_spaces_header">"Prostori u kojima se članovi mogu pridružiti sobi bez pozivnice."</string>
<string name="screen_manage_authorized_spaces_title">"Upravljaj prostorima"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(nepoznati prostor)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Drugi prostori čiji niste član"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vaši prostori"</string>
<string name="screen_media_picker_error_failed_selection">"Odabir medija nije uspio, pokušajte ponovno."</string>
<string name="screen_pinned_timeline_empty_state_description">"Pritisnite poruku i odaberite “%1$s” kako biste uključili ovdje."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Prikvačite važne poruke kako bi ih se lakše moglo pronaći"</string>

View file

@ -112,6 +112,7 @@
<string name="action_load_more">"Továbbiak betöltése"</string>
<string name="action_manage_account">"Fiók kezelése"</string>
<string name="action_manage_devices">"Eszközök kezelése"</string>
<string name="action_manage_rooms">"Szobák kezelése"</string>
<string name="action_message">"Üzenet"</string>
<string name="action_minimize">"Minimalizálás"</string>
<string name="action_next">"Következő"</string>

View file

@ -112,6 +112,7 @@
<string name="action_load_more">"Carica altro"</string>
<string name="action_manage_account">"Gestisci account"</string>
<string name="action_manage_devices">"Gestisci dispositivi"</string>
<string name="action_manage_rooms">"Gestisci le stanze"</string>
<string name="action_message">"Invia messaggio"</string>
<string name="action_minimize">"Riduci"</string>
<string name="action_next">"Avanti"</string>

View file

@ -75,6 +75,7 @@
<string name="action_copy_text">"Copiar texto"</string>
<string name="action_create">"Criar"</string>
<string name="action_create_a_room">"Criar uma sala"</string>
<string name="action_create_space">"Criar espaço"</string>
<string name="action_deactivate">"Desativar"</string>
<string name="action_deactivate_account">"Desativar conta"</string>
<string name="action_decline">"Recusar"</string>
@ -112,6 +113,7 @@
<string name="action_load_more">"Carregar mais"</string>
<string name="action_manage_account">"Gerenciar conta"</string>
<string name="action_manage_devices">"Gerenciar dispositivos"</string>
<string name="action_manage_rooms">"Gerenciar salas"</string>
<string name="action_message">"Mensagem"</string>
<string name="action_minimize">"Minimizar"</string>
<string name="action_next">"Avançar"</string>
@ -161,6 +163,7 @@
<string name="action_static_map_load">"Toque para carregar o mapa"</string>
<string name="action_take_photo">"Tirar foto"</string>
<string name="action_tap_for_options">"Toque para opções"</string>
<string name="action_translate">"Traduzir"</string>
<string name="action_try_again">"Tente novamente"</string>
<string name="action_unpin">"Desafixar"</string>
<string name="action_view">"Visualizar"</string>
@ -190,6 +193,7 @@
<string name="common_copied_to_clipboard">"Copiado para a área de transferência"</string>
<string name="common_copyright">"Direitos autorais"</string>
<string name="common_creating_room">"Criando sala…"</string>
<string name="common_creating_space">"Criando espaço…"</string>
<string name="common_current_user_canceled_knock">"Solicitação cancelada"</string>
<string name="common_current_user_left_room">"Saiu da sala"</string>
<string name="common_current_user_left_space">"Saiu do espaço"</string>
@ -336,6 +340,7 @@ Motivo: %1$s."</string>
<string name="common_starting_chat">"Iniciando a conversa…"</string>
<string name="common_sticker">"Figurinha"</string>
<string name="common_success">"Sucesso"</string>
<string name="common_suggested">"Sugerido"</string>
<string name="common_suggestions">"Sugestões"</string>
<string name="common_syncing">"Sincronizando"</string>
<string name="common_system">"Sistema"</string>
@ -434,11 +439,6 @@ Você tem certeza de que deseja continuar?"</string>
<string name="screen_create_poll_options_section_title">"Opções"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remover %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Configurações"</string>
<string name="screen_manage_authorized_spaces_header">"Os espaços dos quais os membros podem entrar na sala sem um convite."</string>
<string name="screen_manage_authorized_spaces_title">"Gerenciar espaços"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Espaço desconhecido)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Outros espaços dos quais você não é um membro"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Seus espaços"</string>
<string name="screen_media_picker_error_failed_selection">"Falha ao selecionar a mídia, tente novamente."</string>
<string name="screen_pinned_timeline_empty_state_description">"Pressione em uma mensagem e escolha \"%1$s\" para incluir aqui."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Fixe mensagens importantes para que elas possam ser facilmente descobertas"</string>

View file

@ -114,6 +114,7 @@
<string name="action_load_more">"Încărcați mai mult"</string>
<string name="action_manage_account">"Administrare cont"</string>
<string name="action_manage_devices">"Gestionare dispozitive"</string>
<string name="action_manage_rooms">"Gestionați camerele"</string>
<string name="action_message">"Mesaj"</string>
<string name="action_minimize">"Minimizați"</string>
<string name="action_next">"Următorul"</string>
@ -442,11 +443,6 @@ Sunteți sigur că doriți să continuați?"</string>
<string name="screen_create_poll_options_section_title">"Opțiuni"</string>
<string name="screen_create_poll_remove_accessibility_label">"Ștergeți %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Setări"</string>
<string name="screen_manage_authorized_spaces_header">"Spațile din care membrii se pot alătura camerei fără invitație."</string>
<string name="screen_manage_authorized_spaces_title">"Gestionați spațiile"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Spațiu necunoscut)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Alte spații din care nu faceți parte"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Spațiile dumneavoastră"</string>
<string name="screen_media_picker_error_failed_selection">"Selectarea fișierelor media a eșuat, încercați din nou."</string>
<string name="screen_pinned_timeline_empty_state_description">"Apăsați pe un mesaj și alegeți \"%1$s\" pentru a-l include aici."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Fixați mesajele importante, astfel încât să poată fi descoperite cu ușurință"</string>

View file

@ -114,6 +114,7 @@
<string name="action_load_more">"Загрузить еще"</string>
<string name="action_manage_account">"Настройки учетной записи"</string>
<string name="action_manage_devices">"Управление устройствами"</string>
<string name="action_manage_rooms">"Управление комнатами"</string>
<string name="action_message">"Сообщение"</string>
<string name="action_minimize">"Свернуть"</string>
<string name="action_next">"Далее"</string>

View file

@ -114,6 +114,7 @@
<string name="action_load_more">"Načítať viac"</string>
<string name="action_manage_account">"Spravovať účet"</string>
<string name="action_manage_devices">"Spravovať zariadenia"</string>
<string name="action_manage_rooms">"Spravovať miestnosti"</string>
<string name="action_message">"Poslať správu"</string>
<string name="action_minimize">"Minimalizovať"</string>
<string name="action_next">"Ďalej"</string>
@ -439,11 +440,6 @@ Naozaj chcete pokračovať?"</string>
<string name="screen_create_poll_options_section_title">"Možnosti"</string>
<string name="screen_create_poll_remove_accessibility_label">"Odstrániť %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Nastavenia"</string>
<string name="screen_manage_authorized_spaces_header">"Priestory, kde sa členovia môžu pripojiť k miestnosti bez pozvania."</string>
<string name="screen_manage_authorized_spaces_title">"Spravovať priestory"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Neznámy priestor)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Iné priestory, ktorých nie ste členom"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vaše priestory"</string>
<string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
<string name="screen_pinned_timeline_empty_state_description">"Stlačte správu a vyberte možnosť „%1$s“, ktorú chcete zahrnúť sem."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Pripnite dôležité správy, aby sa dali ľahko nájsť"</string>

View file

@ -110,6 +110,7 @@
<string name="action_load_more">"載入更多"</string>
<string name="action_manage_account">"管理帳號"</string>
<string name="action_manage_devices">"管理裝置"</string>
<string name="action_manage_rooms">"管理聊天室"</string>
<string name="action_message">"聊天"</string>
<string name="action_minimize">"最小化"</string>
<string name="action_next">"下一步"</string>

View file

@ -56,6 +56,7 @@
<string name="a11y_your_avatar">"Your avatar"</string>
<string name="action_accept">"Accept"</string>
<string name="action_add_caption">"Add caption"</string>
<string name="action_add_existing_rooms">"Add existing rooms"</string>
<string name="action_add_to_timeline">"Add to timeline"</string>
<string name="action_back">"Back"</string>
<string name="action_call">"Call"</string>
@ -75,6 +76,7 @@
<string name="action_copy_text">"Copy text"</string>
<string name="action_create">"Create"</string>
<string name="action_create_a_room">"Create a room"</string>
<string name="action_create_space">"Create space"</string>
<string name="action_deactivate">"Deactivate"</string>
<string name="action_deactivate_account">"Deactivate account"</string>
<string name="action_decline">"Decline"</string>
@ -192,6 +194,7 @@
<string name="common_copied_to_clipboard">"Copied to clipboard"</string>
<string name="common_copyright">"Copyright"</string>
<string name="common_creating_room">"Creating room…"</string>
<string name="common_creating_space">"Creating space…"</string>
<string name="common_current_user_canceled_knock">"Request canceled"</string>
<string name="common_current_user_left_room">"Left room"</string>
<string name="common_current_user_left_space">"Left space"</string>
@ -338,6 +341,7 @@ Reason: %1$s."</string>
<string name="common_starting_chat">"Starting chat…"</string>
<string name="common_sticker">"Sticker"</string>
<string name="common_success">"Success"</string>
<string name="common_suggested">"Suggested"</string>
<string name="common_suggestions">"Suggestions"</string>
<string name="common_syncing">"Syncing"</string>
<string name="common_system">"System"</string>
@ -375,6 +379,8 @@ Reason: %1$s."</string>
<string name="common_waiting">"Waiting…"</string>
<string name="common_waiting_for_decryption_key">"Waiting for this message"</string>
<string name="common_you">"You"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) shared this message since you were not in the room when it was sent."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s shared this message since you were not in the room when it was sent."</string>
<string name="crypto_history_visible">"This room has been configured so that new members can read history. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"%1$s\'s identity was reset. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"%1$ss %2$s identity was reset. %3$s"</string>
@ -436,11 +442,6 @@ Are you sure you want to continue?"</string>
<string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Settings"</string>
<string name="screen_manage_authorized_spaces_header">"Spaces where members can join the room without an invitation."</string>
<string name="screen_manage_authorized_spaces_title">"Manage spaces"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Unknown space)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Other spaces youre not a member of"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Your spaces"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_pinned_timeline_empty_state_description">"Press on a message and choose “%1$s” to include here."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Pin important messages so that they can be easily discovered"</string>
@ -477,11 +478,18 @@ Are you sure you want to continue?"</string>
<string name="screen_share_open_google_maps">"Open in Google Maps"</string>
<string name="screen_share_open_osm_maps">"Open in OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Share this location"</string>
<string name="screen_space_add_rooms_room_access_description">"Adding a room will not affect the room access. To change the access go to Room info &gt; Privacy &amp; security."</string>
<string name="screen_space_list_description">"Spaces you have created or joined."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_empty_state_title">"Create spaces to organize rooms"</string>
<string name="screen_space_list_parent_space">"%1$s space"</string>
<string name="screen_space_list_title">"Spaces"</string>
<string name="screen_space_menu_action_members">"View members"</string>
<string name="screen_space_remove_rooms_confirmation_content">"Removing a room will not affect the room access. To change the access go to Room info &gt; Privacy &amp; security."</string>
<plurals name="screen_space_remove_rooms_confirmation_title">
<item quantity="one">"Remove room from %1$s"</item>
<item quantity="other">"Remove %1$d rooms from %2$s"</item>
</plurals>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Message not sent because %1$ss verified identity was reset."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Message not sent because %1$s has not verified all devices."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Message not sent because you have not verified one or more of your devices."</string>