Merge branch 'develop' into feature/fga/space_manage_rooms
This commit is contained in:
commit
bb082191e4
495 changed files with 5979 additions and 3199 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ fun RoomAddressField(
|
|||
homeserverName: String,
|
||||
addressValidity: RoomAddressValidity,
|
||||
onAddressChange: (String) -> Unit,
|
||||
label: String,
|
||||
label: String?,
|
||||
supportingText: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 d’auteur"</string>
|
||||
<string name="common_creating_room">"Création du salon…"</string>
|
||||
<string name="common_creating_space">"Création de l’espace…"</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é l’espace"</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 l’historique. %1$s"</string>
|
||||
<string name="crypto_identity_change_pin_violation">"L’identité de %1$s a été réinitialisée. %2$s"</string>
|
||||
<string name="crypto_identity_change_pin_violation_new">"L’identité 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 qu‘il 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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$s’s %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 you’re 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 > Privacy & 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 > Privacy & 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$s’s 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue