Merge branch 'develop' into feature/fga/room_list_filter_iteration

This commit is contained in:
ganfra 2024-03-12 15:40:38 +01:00
commit 23b276dfcb
368 changed files with 6450 additions and 2317 deletions

View file

@ -79,9 +79,9 @@ sealed interface AsyncAction<out T> {
fun isUninitialized(): Boolean = this == Uninitialized
fun isConfirming(): Boolean = this is Confirming
fun isConfirming(): Boolean = this == Confirming
fun isLoading(): Boolean = this is Loading
fun isLoading(): Boolean = this == Loading
fun isFailure(): Boolean = this is Failure

View file

@ -47,3 +47,9 @@ inline fun <R, T> Result<T>.flatMapCatching(transform: (T) -> Result<R>): Result
onFailure = { Result.failure(it) }
)
}
inline fun <T> Result<T>.finally(block: (exception: Throwable?) -> Unit): Result<T> {
onSuccess { block(null) }
onFailure(block)
return this
}

View file

@ -47,6 +47,7 @@ enum class AvatarSize(val dp: Dp) {
InviteSender(16.dp),
EditRoomDetails(70.dp),
RoomListManageUser(70.dp),
NotificationsOptIn(32.dp),

View file

@ -31,6 +31,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -58,10 +59,11 @@ fun ModalBottomSheet(
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
content: @Composable ColumnScope.() -> Unit,
) {
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState
androidx.compose.material3.ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
sheetState = sheetState,
sheetState = safeSheetState,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
@ -102,7 +104,6 @@ private fun ContentToPreview() {
) {
ModalBottomSheet(
onDismissRequest = {},
sheetState = sheetStateForPreview(),
) {
Text(
text = "Sheet Content",

View file

@ -193,7 +193,7 @@ class AsyncIndicatorTests {
currentAnimationState = TransitionStateSnapshot(transitionState),
)
}.test {
var firstItem: Any? = null
var firstItem: Any?
skipItems(1)
state.enqueue(composable = {})
state.enqueue(composable = {})

View file

@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(аватар тоже был изменен)"</string>
<string name="state_event_avatar_url_changed">"%1$s сменили свой аватар"</string>
<string name="state_event_avatar_url_changed_by_you">"Вы сменили аватар"</string>
<string name="state_event_avatar_url_changed_by_you">"Вы сменили изображение"</string>
<string name="state_event_display_name_changed_from">"%1$s изменил свое отображаемое имя с %2$s на %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Вы изменили свое отображаемое имя с %1$s на %2$s"</string>
<string name="state_event_display_name_removed">"%1$s удалил свое отображаемое имя (оно было %2$s)"</string>
@ -13,7 +13,7 @@
<string name="state_event_room_avatar_changed_by_you">"Вы изменили аватар комнаты"</string>
<string name="state_event_room_avatar_removed">"%1$s удалил аватар комнаты"</string>
<string name="state_event_room_avatar_removed_by_you">"Вы удалили аватар комнаты"</string>
<string name="state_event_room_ban">"%1$s заблокирован %2$s"</string>
<string name="state_event_room_ban">"%1$s заблокировал %2$s"</string>
<string name="state_event_room_ban_by_you">"Вы заблокировали %1$s"</string>
<string name="state_event_room_created">"%1$s создал комнату"</string>
<string name="state_event_room_created_by_you">"Вы создали комнату"</string>
@ -23,7 +23,7 @@
<string name="state_event_room_invite_by_you">"Вы пригласили %1$s"</string>
<string name="state_event_room_invite_you">"Пользователь %1$s пригласил вас"</string>
<string name="state_event_room_join">"%1$s присоединился к комнате"</string>
<string name="state_event_room_join_by_you">"Вы вошли в комнату"</string>
<string name="state_event_room_join_by_you">"Вы присоединились к комнате"</string>
<string name="state_event_room_knock">"%1$s запросил присоединение"</string>
<string name="state_event_room_knock_accepted">"%1$s разрешил %2$s присоединиться"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s разрешил вам присоединиться"</string>
@ -39,7 +39,7 @@
<string name="state_event_room_name_changed_by_you">"Вы изменили название комнаты на: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s удалил название комнаты"</string>
<string name="state_event_room_name_removed_by_you">"Вы удалили название комнаты"</string>
<string name="state_event_room_none">"%1$s ничего не изменилось"</string>
<string name="state_event_room_none">"%1$s ничего не изменил"</string>
<string name="state_event_room_none_by_you">"Вы не внесли никаких изменений"</string>
<string name="state_event_room_reject">"%1$s отклонил приглашение"</string>
<string name="state_event_room_reject_by_you">"Вы отклонили приглашение"</string>
@ -53,7 +53,7 @@
<string name="state_event_room_topic_changed_by_you">"Вы изменили тему на: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s удалил тему комнаты"</string>
<string name="state_event_room_topic_removed_by_you">"Вы удалили тему комнаты"</string>
<string name="state_event_room_unban">"%1$s разблокирован %2$s"</string>
<string name="state_event_room_unban">"%1$s разблокировал %2$s"</string>
<string name="state_event_room_unban_by_you">"Вы разблокировали %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s внес неизвестное изменение для своих участников"</string>
</resources>

View file

@ -3,12 +3,16 @@
<string name="state_event_avatar_changed_too">"(avatar was changed too)"</string>
<string name="state_event_avatar_url_changed">"%1$s changed their avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"You changed your avatar"</string>
<string name="state_event_demoted_to_member">"%1$s was demoted to member"</string>
<string name="state_event_demoted_to_moderator">"%1$s was demoted to moderator"</string>
<string name="state_event_display_name_changed_from">"%1$s changed their display name from %2$s to %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"You changed your display name from %1$s to %2$s"</string>
<string name="state_event_display_name_removed">"%1$s removed their display name (it was %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"You removed your display name (it was %1$s)"</string>
<string name="state_event_display_name_set">"%1$s set their display name to %2$s"</string>
<string name="state_event_display_name_set_by_you">"You set your display name to %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s was promoted to admin"</string>
<string name="state_event_promoted_to_moderator">"%1$s was promoted to moderator"</string>
<string name="state_event_room_avatar_changed">"%1$s changed the room avatar"</string>
<string name="state_event_room_avatar_changed_by_you">"You changed the room avatar"</string>
<string name="state_event_room_avatar_removed">"%1$s removed the room avatar"</string>

View file

@ -82,4 +82,11 @@ enum class FeatureFlags(
defaultValue = true,
isFinished = false,
),
RoomModeration(
key = "feature.roomModeration",
title = "Room moderation",
description = "Add moderation features to the room for users with permissions",
defaultValue = true,
isFinished = false,
),
}

View file

@ -41,6 +41,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.Mentions -> true
FeatureFlags.MarkAsUnread -> false
FeatureFlags.RoomListFilters -> false
FeatureFlags.RoomModeration -> false
}
} else {
false

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api
interface SdkMetadata {
val sdkGitSha: String
}

View file

@ -29,12 +29,18 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import java.io.Closeable
import java.io.File
@ -91,6 +97,10 @@ interface MatrixRoom : Closeable {
suspend fun unsubscribeFromSync()
suspend fun userRole(userId: UserId): Result<RoomMember.Role>
suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit>
suspend fun userDisplayName(userId: UserId): Result<String?>
suspend fun userAvatarUrl(userId: UserId): Result<String?>
@ -129,6 +139,8 @@ interface MatrixRoom : Closeable {
suspend fun canUserInvite(userId: UserId): Result<Boolean>
suspend fun canUserKick(userId: UserId): Result<Boolean>
suspend fun canUserBan(userId: UserId): Result<Boolean>
suspend fun canUserRedactOwn(userId: UserId): Result<Boolean>
@ -144,6 +156,18 @@ interface MatrixRoom : Closeable {
suspend fun canUserJoinCall(userId: UserId): Result<Boolean> =
canUserSendState(userId, StateEventType.CALL_MEMBER)
fun usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
return roomInfoFlow
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
.distinctUntilChanged()
.combine(membersStateFlow) { powerLevels, membersState ->
membersState.roomMembers()
.orEmpty()
.filter { powerLevels.containsKey(it.userId) }
.toPersistentList()
}
}
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>
@ -154,6 +178,12 @@ interface MatrixRoom : Closeable {
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
suspend fun kickUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun banUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun unbanUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit>
/**

View file

@ -17,8 +17,10 @@
package io.element.android.libraries.matrix.api.room
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@Immutable
data class MatrixRoomInfo(
@ -39,6 +41,7 @@ data class MatrixRoomInfo(
val activeMembersCount: Long,
val invitedMembersCount: Long,
val joinedMembersCount: Long,
val userPowerLevels: ImmutableMap<UserId, Long>,
val highlightCount: Long,
val notificationCount: Long,
val userDefinedNotificationMode: RoomNotificationMode?,

View file

@ -32,10 +32,20 @@ data class RoomMember(
/**
* Role of the RoomMember, based on its [powerLevel].
*/
enum class Role {
ADMIN,
MODERATOR,
USER
enum class Role(val powerLevel: Long) {
ADMIN(100L),
MODERATOR(50L),
USER(0L);
companion object {
fun forPowerLevel(powerLevel: Long): Role {
return when {
powerLevel >= ADMIN.powerLevel -> ADMIN
powerLevel >= MODERATOR.powerLevel -> MODERATOR
else -> USER
}
}
}
}
/**

View file

@ -25,6 +25,11 @@ import io.element.android.libraries.matrix.api.room.StateEventType
*/
suspend fun MatrixRoom.canInvite(): Result<Boolean> = canUserInvite(sessionId)
/**
* Shortcut for calling [MatrixRoom.canUserKick] with our own user.
*/
suspend fun MatrixRoom.canKick(): Result<Boolean> = canUserKick(sessionId)
/**
* Shortcut for calling [MatrixRoom.canBanUser] with our own user.
*/

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room.powerlevels
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
data class UserRoleChange(
val userId: UserId,
val role: RoomMember.Role,
) {
val powerLevel: Long = role.powerLevel
}

View file

@ -39,6 +39,7 @@ interface MatrixTimeline : AutoCloseable {
val paginationState: StateFlow<PaginationState>
val timelineItems: Flow<List<MatrixTimelineItem>>
val membershipChangeEventReceived: Flow<Unit>
suspend fun paginateBackwards(requestSize: Int): Result<Unit>
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>

View file

@ -30,7 +30,6 @@ data class TracingFilterConfiguration(
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_UI_TIMELINE to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_READ_RECEIPTS to LogLevel.TRACE,
)
fun getLogLevel(target: Target): LogLevel {
@ -87,7 +86,6 @@ object TracingFilterConfigurations {
val nightly = TracingFilterConfiguration(
overrides = mapOf(
Target.ELEMENT to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_READ_RECEIPTS to LogLevel.TRACE,
),
)
val debug = TracingFilterConfiguration(

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.SdkMetadata
import org.matrix.rustcomponents.sdk.sdkGitSha
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class RustSdkMetadata @Inject constructor() : SdkMetadata {
override val sdkGitSha: String
get() = sdkGitSha()
}

View file

@ -16,12 +16,15 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentMap
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.Membership as RustMembership
import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo
@ -45,10 +48,11 @@ class MatrixRoomInfoMapper(
alternativeAliases = it.alternativeAliases.toImmutableList(),
currentUserMembership = it.membership.map(),
latestEvent = it.latestEvent?.use(timelineItemMapper::map),
inviter = it.inviter?.use(RoomMemberMapper::map),
inviter = it.inviter?.let(RoomMemberMapper::map),
activeMembersCount = it.activeMembersCount.toLong(),
invitedMembersCount = it.invitedMembersCount.toLong(),
joinedMembersCount = it.joinedMembersCount.toLong(),
userPowerLevels = mapPowerLevels(it.userPowerLevels),
highlightCount = it.highlightCount.toLong(),
notificationCount = it.notificationCount.toLong(),
userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
@ -69,3 +73,7 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
}
fun mapPowerLevels(powerLevels: Map<String, Long>): ImmutableMap<UserId, Long> {
return powerLevels.mapKeys { (key, _) -> UserId(key) }.toPersistentMap()
}

View file

@ -36,8 +36,10 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
@ -51,6 +53,7 @@ import io.element.android.libraries.matrix.impl.notificationsettings.RustNotific
import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
@ -65,6 +68,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EventTimelineItem
@ -74,6 +79,7 @@ import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
import org.matrix.rustcomponents.sdk.WidgetCapabilities
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
@ -151,6 +157,12 @@ class RustMatrixRoom(
override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow()
init {
timeline.membershipChangeEventReceived
.onEach { roomMemberListFetcher.fetchRoomMembers() }
.launchIn(roomCoroutineScope)
}
override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId)
override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId)
@ -228,6 +240,19 @@ class RustMatrixRoom(
}
}
override suspend fun userRole(userId: UserId): Result<RoomMember.Role> = withContext(coroutineDispatchers.io) {
runCatching {
RoomMemberMapper.mapRole(innerRoom.suggestedRoleForUser(userId.value))
}
}
override suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit> {
return runCatching {
val powerLevelChanges = changes.map { UserPowerLevelUpdate(it.userId.value, it.powerLevel) }
innerRoom.updatePowerLevelsForUsers(powerLevelChanges)
}
}
override suspend fun userAvatarUrl(userId: UserId): Result<String?> = withContext(roomDispatcher) {
runCatching {
innerRoom.memberAvatarUrl(userId.value)
@ -319,6 +344,12 @@ class RustMatrixRoom(
}
}
override suspend fun canUserKick(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserKick(userId.value)
}
}
override suspend fun canUserBan(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserBan(userId.value)
@ -448,6 +479,24 @@ class RustMatrixRoom(
}
}
override suspend fun kickUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.kickUser(userId.value, reason)
}
}
override suspend fun banUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.banUser(userId.value, reason)
}
}
override suspend fun unbanUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.unbanUser(userId.value, reason)
}
}
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.setIsFavourite(isFavorite, null)

View file

@ -19,7 +19,6 @@ package io.element.android.libraries.matrix.impl.room.member
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.impl.util.destroyAll
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ensureActive
@ -41,7 +40,7 @@ import kotlin.coroutines.coroutineContext
internal class RoomMemberListFetcher(
private val room: RoomInterface,
private val dispatcher: CoroutineDispatcher,
private val pageSize: Int = 1000,
private val pageSize: Int = 10_000,
) {
private val updatedRoomMemberMutex = Mutex()
private val roomId = room.id()
@ -66,7 +65,7 @@ internal class RoomMemberListFetcher(
if (_membersFlow.value !is MatrixRoomMembersState.Ready) {
fetchCachedRoomMembers()
} else {
Timber.i("No need to load cached members found for room $roomId")
Timber.i("Cached members not found for $roomId")
}
}
@ -109,13 +108,8 @@ internal class RoomMemberListFetcher(
// We should probably implement some sort of paging in the future.
coroutineContext.ensureActive()
val chunk = iterator.nextChunk(pageSize.toUInt())
val members = try {
// Load next chunk. If null (no more items), exit the loop
chunk?.parallelMap(RoomMemberMapper::map) ?: break
} finally {
// Make sure we clear all member references
chunk?.destroyAll()
}
// Load next chunk. If null (no more items), exit the loop
val members = chunk?.parallelMap(RoomMemberMapper::map) ?: break
addAll(members)
Timber.i("Emitting first $size members for room $roomId")
_membersFlow.value = MatrixRoomMembersState.Ready(toImmutableList())

View file

@ -24,19 +24,17 @@ import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
object RoomMemberMapper {
fun map(roomMember: RustRoomMember): RoomMember = roomMember.use {
RoomMember(
UserId(it.userId()),
it.displayName(),
it.avatarUrl(),
mapMembership(it.membership()),
it.isNameAmbiguous(),
it.powerLevel(),
it.normalizedPowerLevel(),
it.isIgnored(),
mapRole(it.suggestedRoleForPowerLevel())
fun map(roomMember: RustRoomMember): RoomMember = RoomMember(
UserId(roomMember.userId),
roomMember.displayName,
roomMember.avatarUrl,
mapMembership(roomMember.membership),
roomMember.isNameAmbiguous,
roomMember.powerLevel,
roomMember.normalizedPowerLevel,
roomMember.isIgnored,
mapRole(roomMember.suggestedRoleForPowerLevel),
)
}
fun mapRole(role: RoomMemberRole): RoomMember.Role =
when (role) {

View file

@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
@ -55,6 +56,8 @@ class AsyncMatrixTimeline(
}
private val closeSignal = CompletableDeferred<Unit>()
override val membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
init {
coroutineScope.launch {
val delegateTimeline = timeline.await()
@ -64,6 +67,9 @@ class AsyncMatrixTimeline(
delegateTimeline.paginationState
.onEach { _paginationState.value = it }
.launchIn(this)
delegateTimeline.membershipChangeEventReceived
.onEach { membershipChangeEventReceived.emit(it) }
.launchIn(this)
launch {
withContext(NonCancellable) {

View file

@ -17,6 +17,9 @@
package io.element.android.libraries.matrix.impl.timeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -31,6 +34,9 @@ internal class MatrixTimelineDiffProcessor(
) {
private val mutex = Mutex()
private val _membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val membershipChangeEventReceived: Flow<Unit> = _membershipChangeEventReceived
suspend fun postItems(items: List<TimelineItem>) {
updateTimelineItems {
Timber.v("Update timeline items from postItems (with ${items.size} items) on ${Thread.currentThread()}")
@ -63,6 +69,11 @@ internal class MatrixTimelineDiffProcessor(
}
TimelineChange.PUSH_BACK -> {
val item = diff.pushBack()?.asMatrixTimelineItem() ?: return
if (item is MatrixTimelineItem.Event && item.event.content is RoomMembershipContent) {
// TODO - This is a temporary solution to notify the room screen about membership changes
// Ideally, this should be implemented by the Rust SDK
_membershipChangeEventReceived.tryEmit(Unit)
}
add(item)
}
TimelineChange.PUSH_FRONT -> {

View file

@ -114,6 +114,8 @@ class RustMatrixTimeline(
)
}
override val membershipChangeEventReceived: Flow<Unit> = timelineDiffProcessor.membershipChangeEventReceived
init {
Timber.d("Initialize timeline for room ${matrixRoom.roomId}")

View file

@ -38,6 +38,8 @@ class RustTracingService @Inject constructor(private val buildMeta: BuildMeta) :
is WriteToFilesConfiguration.Enabled -> TracingFileConfiguration(
path = writeToFilesConfiguration.directory,
filePrefix = writeToFilesConfiguration.filenamePrefix,
fileSuffix = null,
maxFiles = null,
)
},
)

View file

@ -42,9 +42,9 @@ class RoomMemberListFetcherTest {
val room = FakeRustRoom(getMembersNoSync = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
fakeRustRoomMember(A_USER_ID),
fakeRustRoomMember(A_USER_ID_2),
fakeRustRoomMember(A_USER_ID_3),
)
)
})
@ -94,9 +94,9 @@ class RoomMemberListFetcherTest {
val room = FakeRustRoom(getMembersNoSync = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
fakeRustRoomMember(A_USER_ID),
fakeRustRoomMember(A_USER_ID_2),
fakeRustRoomMember(A_USER_ID_3),
)
)
})
@ -118,9 +118,9 @@ class RoomMemberListFetcherTest {
val room = FakeRustRoom(getMembers = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
fakeRustRoomMember(A_USER_ID),
fakeRustRoomMember(A_USER_ID_2),
fakeRustRoomMember(A_USER_ID_3),
)
)
})
@ -153,14 +153,14 @@ class RoomMemberListFetcherTest {
fun `fetchRoomMembers - with 'withCache' returns cached items first, then new ones`() = runTest {
val room = FakeRustRoom(
getMembersNoSync = {
FakeRoomMembersIterator(listOf(FakeRustRoomMember(A_USER_ID_4)))
FakeRoomMembersIterator(listOf(fakeRustRoomMember(A_USER_ID_4)))
},
getMembers = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
fakeRustRoomMember(A_USER_ID),
fakeRustRoomMember(A_USER_ID_2),
fakeRustRoomMember(A_USER_ID_3),
)
)
}
@ -189,14 +189,14 @@ class RoomMemberListFetcherTest {
fun `fetchRoomMembers - with 'withCache' skips cache if there is already a ready state`() = runTest {
val room = FakeRustRoom(
getMembersNoSync = {
FakeRoomMembersIterator(listOf(FakeRustRoomMember(A_USER_ID_4)))
FakeRoomMembersIterator(listOf(fakeRustRoomMember(A_USER_ID_4)))
},
getMembers = {
FakeRoomMembersIterator(
listOf(
FakeRustRoomMember(A_USER_ID),
FakeRustRoomMember(A_USER_ID_2),
FakeRustRoomMember(A_USER_ID_3),
fakeRustRoomMember(A_USER_ID),
fakeRustRoomMember(A_USER_ID_2),
fakeRustRoomMember(A_USER_ID_3),
)
)
}
@ -262,48 +262,23 @@ class FakeRoomMembersIterator(
}
}
class FakeRustRoomMember(
private val userId: UserId,
private val displayName: String? = null,
private val avatarUrl: String? = null,
private val membership: MembershipState = MembershipState.JOIN,
private val isNameAmbiguous: Boolean = false,
private val powerLevel: Long = 0L,
private val role: RoomMemberRole = RoomMemberRole.USER,
) : RoomMember(NoPointer) {
override fun userId(): String {
return userId.value
}
override fun displayName(): String? {
return displayName
}
override fun avatarUrl(): String? {
return avatarUrl
}
override fun membership(): MembershipState {
return membership
}
override fun isNameAmbiguous(): Boolean {
return isNameAmbiguous
}
override fun powerLevel(): Long {
return powerLevel
}
override fun normalizedPowerLevel(): Long {
return powerLevel
}
override fun isIgnored(): Boolean {
return false
}
override fun suggestedRoleForPowerLevel(): RoomMemberRole {
return role
}
}
private fun fakeRustRoomMember(
userId: UserId,
displayName: String? = null,
avatarUrl: String? = null,
membership: MembershipState = MembershipState.JOIN,
isNameAmbiguous: Boolean = false,
powerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMemberRole = RoomMemberRole.USER,
) = RoomMember(
userId = userId.value,
displayName = displayName,
avatarUrl = avatarUrl,
membership = membership,
isNameAmbiguous = isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = powerLevel,
isIgnored = isIgnored,
suggestedRoleForPowerLevel = role,
)

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.SdkMetadata
class FakeSdkMetadata(override val sdkGitSha: String) : SdkMetadata

View file

@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -54,6 +55,8 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@ -86,10 +89,12 @@ class FakeMatrixRoom(
private var unignoreResult: Result<Unit> = Result.success(Unit)
private var userDisplayNameResult = Result.success<String?>(null)
private var userAvatarUrlResult = Result.success<String?>(null)
private var userRoleResult = Result.success(RoomMember.Role.USER)
private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var joinRoomResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private var canKickResult = Result.success(false)
private var canBanResult = Result.success(false)
private var canRedactOwnResult = Result.success(canRedactOwn)
private var canRedactOtherResult = Result.success(canRedactOther)
@ -100,11 +105,15 @@ class FakeMatrixRoom(
private var setTopicResult = Result.success(Unit)
private var updateAvatarResult = Result.success(Unit)
private var removeAvatarResult = Result.success(Unit)
private var updateUserRoleResult = Result.success(Unit)
private var toggleReactionResult = Result.success(Unit)
private var retrySendMessageResult = Result.success(Unit)
private var cancelSendResult = Result.success(Unit)
private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit)
private var kickUserResult = Result.success(Unit)
private var banUserResult = Result.success(Unit)
private var unBanUserResult = Result.success(Unit)
private var sendLocationResult = Result.success(Unit)
private var createPollResult = Result.success(Unit)
private var editPollResult = Result.success(Unit)
@ -206,6 +215,14 @@ class FakeMatrixRoom(
userAvatarUrlResult
}
override suspend fun userRole(userId: UserId): Result<RoomMember.Role> {
return userRoleResult
}
override suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit> {
return updateUserRoleResult
}
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>) = simulateLongTask {
sendMessageMentions = mentions
Result.success(Unit)
@ -285,6 +302,10 @@ class FakeMatrixRoom(
return canBanResult
}
override suspend fun canUserKick(userId: UserId): Result<Boolean> {
return canKickResult
}
override suspend fun canUserInvite(userId: UserId): Result<Boolean> {
return canInviteResult
}
@ -384,6 +405,18 @@ class FakeMatrixRoom(
return reportContentResult
}
override suspend fun kickUser(userId: UserId, reason: String?): Result<Unit> {
return kickUserResult
}
override suspend fun banUser(userId: UserId, reason: String?): Result<Unit> {
return banUserResult
}
override suspend fun unbanUser(userId: UserId, reason: String?): Result<Unit> {
return unBanUserResult
}
val setIsFavoriteCalls = mutableListOf<Boolean>()
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> {
@ -496,10 +529,22 @@ class FakeMatrixRoom(
userAvatarUrlResult = avatarUrl
}
fun givenUserRoleResult(role: Result<RoomMember.Role>) {
userRoleResult = role
}
fun givenUpdateUserRoleResult(result: Result<Unit>) {
updateUserRoleResult = result
}
fun givenJoinRoomResult(result: Result<Unit>) {
joinRoomResult = result
}
fun givenCanKickResult(result: Result<Boolean>) {
canKickResult = result
}
fun givenCanBanResult(result: Result<Boolean>) {
canBanResult = result
}
@ -576,6 +621,18 @@ class FakeMatrixRoom(
reportContentResult = result
}
fun givenKickUserResult(result: Result<Unit>) {
kickUserResult = result
}
fun givenBanUserResult(result: Result<Unit>) {
banUserResult = result
}
fun givenUnbanUserResult(result: Result<Unit>) {
unBanUserResult = result
}
fun givenSendLocationResult(result: Result<Unit>) {
sendLocationResult = result
}
@ -668,6 +725,7 @@ fun aRoomInfo(
notificationCount: Long = 0,
userDefinedNotificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
activeRoomCallParticipants: List<String> = emptyList()
) = MatrixRoomInfo(
id = id,
@ -691,5 +749,6 @@ fun aRoomInfo(
notificationCount = notificationCount,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = hasRoomCall,
userPowerLevels = userPowerLevels,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
)

View file

@ -24,6 +24,7 @@ import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
@ -59,6 +60,8 @@ class FakeMatrixTimeline(
override suspend fun paginateBackwards(requestSize: Int) = paginateBackwards()
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int) = paginateBackwards()
override val membershipChangeEventReceived = MutableSharedFlow<Unit>()
private suspend fun paginateBackwards(): Result<Unit> {
updatePaginationState {
copy(isBackPaginating = true)
@ -73,6 +76,10 @@ class FakeMatrixTimeline(
return Result.success(Unit)
}
fun givenMembershipChangeEventReceived() {
membershipChangeEventReceived.tryEmit(Unit)
}
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = simulateLongTask {
Result.success(Unit)
}

View file

@ -51,6 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SelectedUser(
matrixUser: MatrixUser,
canRemove: Boolean,
onUserRemoved: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
) {
@ -70,24 +71,26 @@ fun SelectedUser(
style = MaterialTheme.typography.bodyLarge,
)
}
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
.clickable(
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() },
onClick = { onUserRemoved(matrixUser) }
),
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(id = CommonStrings.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(2.dp)
)
if (canRemove) {
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.size(20.dp)
.align(Alignment.TopEnd)
.clickable(
indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() },
onClick = { onUserRemoved(matrixUser) }
),
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(id = CommonStrings.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(2.dp)
)
}
}
}
}
@ -97,6 +100,17 @@ fun SelectedUser(
internal fun SelectedUserPreview() = ElementPreview {
SelectedUser(
aMatrixUser(),
canRemove = true,
onUserRemoved = {},
)
}
@PreviewsDayNight
@Composable
internal fun SelectedUserCannotRemovePreview() = ElementPreview {
SelectedUser(
aMatrixUser(),
canRemove = false,
onUserRemoved = {},
)
}

View file

@ -46,11 +46,12 @@ import kotlinx.collections.immutable.toImmutableList
import kotlin.math.floor
@Composable
fun SelectedUsersList(
fun SelectedUsersRowList(
selectedUsers: ImmutableList<MatrixUser>,
onUserRemoved: (MatrixUser) -> Unit,
modifier: Modifier = Modifier,
autoScroll: Boolean = false,
canDeselect: (MatrixUser) -> Boolean = { true },
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val lazyListState = rememberLazyListState()
@ -105,11 +106,12 @@ fun SelectedUsersList(
.fillMaxWidth(),
contentPadding = contentPadding,
) {
itemsIndexed(selectedUsers.toList()) { index, matrixUser ->
itemsIndexed(selectedUsers.toList()) { index, selectedUser ->
Layout(
content = {
SelectedUser(
matrixUser = matrixUser,
matrixUser = selectedUser,
canRemove = canDeselect(selectedUser),
onUserRemoved = onUserRemoved,
)
},
@ -133,7 +135,7 @@ fun SelectedUsersList(
internal fun SelectedUsersListPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Two users that will be visible with no scrolling
SelectedUsersList(
SelectedUsersRowList(
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
onUserRemoved = {},
modifier = Modifier
@ -143,7 +145,7 @@ internal fun SelectedUsersListPreview() = ElementPreview {
// Multiple users that don't fit, so will be spaced out per the measure policy
for (i in 0..5) {
SelectedUsersList(
SelectedUsersRowList(
selectedUsers = aMatrixUserList().take(6).toImmutableList(),
onUserRemoved = {},
modifier = Modifier

View file

@ -18,9 +18,12 @@ package io.element.android.libraries.matrix.ui.room
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
@ -45,3 +48,10 @@ fun MatrixRoom.canRedactOtherAsState(updateKey: Long): State<Boolean> {
value = canRedactOther().getOrElse { false }
}
}
@Composable
fun MatrixRoom.isOwnUserAdmin(): Boolean {
val roomInfo by roomInfoFlow.collectAsState(initial = null)
val powerLevel = roomInfo?.userPowerLevels?.get(sessionId) ?: 0L
return RoomMember.Role.forPowerLevel(powerLevel) == RoomMember.Role.ADMIN
}

View file

@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
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.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
@ -57,7 +58,6 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
@ -80,7 +80,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
@ -147,13 +146,37 @@ private fun MediaImageView(
}
}
@UnstableApi
@Composable
private fun MediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Text(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary)
.wrapContentSize(),
text = "A Video Player will render here",
)
} else {
ExoPlayerMediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
onClick = onClick,
modifier = modifier,
)
}
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var playableState: PlayableState.Playable by remember {
mutableStateOf(PlayableState.Playable(isPlaying = false, isShowingControls = false))

View file

@ -172,7 +172,7 @@ fun TextComposer(
}
}
val canSendMessage by remember { derivedStateOf { state.messageHtml.isNotEmpty() } }
val canSendMessage by remember { derivedStateOf { state.messageMarkdown.isNotBlank() } }
val sendButton = @Composable {
SendButton(
canSendMessage = canSendMessage,
@ -598,7 +598,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
items = persistentListOf(
{
ATextComposer(
RichTextEditorState("", initialFocus = true),
aRichTextEditorState(initialText = "", initialFocus = true),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
@ -608,7 +608,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
},
{
ATextComposer(
RichTextEditorState("A message", initialFocus = true),
aRichTextEditorState(initialText = "A message", initialFocus = true),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
@ -618,8 +618,8 @@ internal fun TextComposerSimplePreview() = ElementPreview {
},
{
ATextComposer(
RichTextEditorState(
"A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
aRichTextEditorState(
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
initialFocus = true
),
voiceMessageState = VoiceMessageState.Idle,
@ -631,7 +631,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
},
{
ATextComposer(
RichTextEditorState("A message without focus", initialFocus = false),
aRichTextEditorState(initialText = "A message without focus", initialFocus = false),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
@ -648,7 +648,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
internal fun TextComposerFormattingPreview() = ElementPreview {
PreviewColumn(items = persistentListOf({
ATextComposer(
RichTextEditorState("", initialFocus = false),
aRichTextEditorState(initialText = "", initialFocus = false),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
@ -658,7 +658,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
)
}, {
ATextComposer(
RichTextEditorState("A message", initialFocus = false),
aRichTextEditorState(initialText = "A message", initialFocus = false),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
@ -668,7 +668,10 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
)
}, {
ATextComposer(
RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", initialFocus = false),
aRichTextEditorState(
initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow",
initialFocus = false
),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
@ -684,7 +687,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
internal fun TextComposerEditPreview() = ElementPreview {
PreviewColumn(items = persistentListOf({
ATextComposer(
RichTextEditorState("A message", initialFocus = true),
aRichTextEditorState(initialText = "A message", initialFocus = true),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")),
enableTextFormatting = true,
@ -701,7 +704,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
items = persistentListOf(
{
ATextComposer(
RichTextEditorState(""),
aRichTextEditorState(""),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = false,
@ -737,7 +740,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
},
{
ATextComposer(
RichTextEditorState("A message"),
aRichTextEditorState(initialText = "A message"),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = true,
@ -758,7 +761,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
},
{
ATextComposer(
RichTextEditorState("A message"),
aRichTextEditorState(initialText = "A message"),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = false,
@ -779,7 +782,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
},
{
ATextComposer(
RichTextEditorState("A message"),
aRichTextEditorState(initialText = "A message"),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = false,
@ -800,7 +803,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
},
{
ATextComposer(
RichTextEditorState("A message", initialFocus = true),
aRichTextEditorState(initialText = "A message", initialFocus = true),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = false,
@ -923,3 +926,14 @@ private fun ATextComposer(
onRichContentSelected = null,
)
}
fun aRichTextEditorState(
initialText: String = "",
initialHtml: String = initialText,
initialMarkdown: String = initialText,
initialFocus: Boolean = false,
) = RichTextEditorState(
initialHtml = initialHtml,
initialMarkdown = initialMarkdown,
initialFocus = initialFocus,
)

View file

@ -21,4 +21,5 @@
<string name="rich_text_editor_remove_link">"Ta bort länk"</string>
<string name="rich_text_editor_unindent">"Ta bort indrag"</string>
<string name="rich_text_editor_url_placeholder">"Länk"</string>
<string name="screen_room_voice_message_tooltip">"Håll för att spela in"</string>
</resources>

View file

@ -102,6 +102,7 @@
<string name="common_analytics">"Статистика"</string>
<string name="common_appearance">"Облик"</string>
<string name="common_audio">"Аудио"</string>
<string name="common_blocked_users">"Блокирани потребители"</string>
<string name="common_chat_backup">"Резервно копие на чатовете"</string>
<string name="common_creating_room">"Създаване на стая…"</string>
<string name="common_dark">"Тъмен"</string>
@ -114,6 +115,7 @@
<string name="common_encryption_enabled">"Шифроването е включено"</string>
<string name="common_enter_your_pin">"Въведете своя PIN"</string>
<string name="common_error">"Грешка"</string>
<string name="common_favourite">"Фаворизиране"</string>
<string name="common_file">"Файл"</string>
<string name="common_forward_message">"Препращане на съобщението"</string>
<string name="common_gif">"GIF"</string>
@ -134,6 +136,7 @@
<string name="common_mute">"Заглушаване"</string>
<string name="common_no_results">"Няма резултати"</string>
<string name="common_offline">"Офлайн"</string>
<string name="common_or">"или"</string>
<string name="common_password">"Парола"</string>
<string name="common_people">"Хора"</string>
<string name="common_permalink">"Постоянна връзка"</string>
@ -185,6 +188,7 @@
<string name="common_username">"Потребителско име"</string>
<string name="common_verification_cancelled">"Потвърждаването е отменено"</string>
<string name="common_verification_complete">"Потвърждаването е завършено"</string>
<string name="common_verify_device">"Потвърждаване на устройството"</string>
<string name="common_video">"Видео"</string>
<string name="common_voice_message">"Гласово съобщение"</string>
<string name="common_waiting_for_decryption_key">"В очакване на това съобщение"</string>

View file

@ -59,6 +59,7 @@
<string name="action_enter_pin">"Zadejte PIN"</string>
<string name="action_forgot_password">"Zapomněli jste heslo?"</string>
<string name="action_forward">"Přeposlat"</string>
<string name="action_go_back">"Přejít zpět"</string>
<string name="action_invite">"Pozvat"</string>
<string name="action_invite_friends">"Pozvat přátele"</string>
<string name="action_invite_friends_to_app">"Pozvat přátele do %1$s"</string>
@ -86,6 +87,7 @@
<string name="action_reply_in_thread">"Odpovědět ve vlákně"</string>
<string name="action_report_bug">"Nahlásit chybu"</string>
<string name="action_report_content">"Nahlásit obsah"</string>
<string name="action_reset">"Obnovit"</string>
<string name="action_retry">"Zkusit znovu"</string>
<string name="action_retry_decryption">"Opakovat dešifrování"</string>
<string name="action_save">"Uložit"</string>
@ -115,6 +117,7 @@
<string name="common_audio">"Zvuk"</string>
<string name="common_blocked_users">"Blokovaní uživatelé"</string>
<string name="common_bubbles">"Bubliny"</string>
<string name="common_call_invite">"Probíhá hovor (nepodporováno)"</string>
<string name="common_chat_backup">"Záloha chatu"</string>
<string name="common_copyright">"Autorská práva"</string>
<string name="common_creating_room">"Vytváření místnosti…"</string>
@ -185,6 +188,8 @@
<string name="common_room">"Místnost"</string>
<string name="common_room_name">"Název místnosti"</string>
<string name="common_room_name_placeholder">"např. název vašeho projektu"</string>
<string name="common_saved_changes">"Uložené změny"</string>
<string name="common_saving">"Ukládání"</string>
<string name="common_screen_lock">"Zámek obrazovky"</string>
<string name="common_search_for_someone">"Hledat někoho"</string>
<string name="common_search_results">"Výsledky hledání"</string>
@ -227,6 +232,8 @@
<string name="dialog_title_error">"Chyba"</string>
<string name="dialog_title_success">"Úspěch"</string>
<string name="dialog_title_warning">"Upozornění"</string>
<string name="dialog_unsaved_changes_description_android">"Vaše změny nebyly uloženy. Opravdu se chcete vrátit?"</string>
<string name="dialog_unsaved_changes_title">"Uložit změny?"</string>
<string name="error_failed_creating_the_permalink">"Vytvoření trvalého odkazu se nezdařilo"</string>
<string name="error_failed_loading_map">"%1$s nemohl načíst mapu. Zkuste to prosím později."</string>
<string name="error_failed_loading_messages">"Načítání zpráv se nezdařilo"</string>

View file

@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_delete">"Удалить"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"Введена цифра %1$d"</item>
<item quantity="few">"Ведено %1$d цифр"</item>
<item quantity="one">"Введена %1$d цифра"</item>
<item quantity="few">"Ведено %1$d цифры"</item>
<item quantity="many">"Введено много цифр"</item>
</plurals>
<string name="a11y_hide_password">"Скрыть пароль"</string>
@ -59,6 +59,7 @@
<string name="action_enter_pin">"Введите PIN-код"</string>
<string name="action_forgot_password">"Забыли пароль?"</string>
<string name="action_forward">"Переслать"</string>
<string name="action_go_back">"Вернуться"</string>
<string name="action_invite">"Пригласить"</string>
<string name="action_invite_friends">"Пригласить друзей"</string>
<string name="action_invite_friends_to_app">"Пригласить друзей в %1$s"</string>
@ -86,6 +87,7 @@
<string name="action_reply_in_thread">"Ответить в теме"</string>
<string name="action_report_bug">"Сообщить об ошибке"</string>
<string name="action_report_content">"Пожаловаться на содержание"</string>
<string name="action_reset">"Сбросить"</string>
<string name="action_retry">"Повторить"</string>
<string name="action_retry_decryption">"Повторите расшифровку"</string>
<string name="action_save">"Сохранить"</string>
@ -115,6 +117,7 @@
<string name="common_audio">"Аудио"</string>
<string name="common_blocked_users">"Заблокированные пользователи"</string>
<string name="common_bubbles">"Пузыри"</string>
<string name="common_call_invite">"Выполняется звонок (не поддерживается)"</string>
<string name="common_chat_backup">"Резервная копия чатов"</string>
<string name="common_copyright">"Авторское право"</string>
<string name="common_creating_room">"Создание комнаты…"</string>
@ -122,7 +125,7 @@
<string name="common_dark">"Темная"</string>
<string name="common_decryption_error">"Ошибка расшифровки"</string>
<string name="common_developer_options">"Для разработчика"</string>
<string name="common_direct_chat">"Прямой чат"</string>
<string name="common_direct_chat">"Личный чат"</string>
<string name="common_edited_suffix">"(изменено)"</string>
<string name="common_editing">"Редактирование"</string>
<string name="common_emote">"%1$s%2$s"</string>
@ -185,6 +188,8 @@
<string name="common_room">"Комната"</string>
<string name="common_room_name">"Название комнаты"</string>
<string name="common_room_name_placeholder">"например, название вашего проекта"</string>
<string name="common_saved_changes">"Сохраненные изменения"</string>
<string name="common_saving">"Сохранение"</string>
<string name="common_screen_lock">"Блокировка экрана"</string>
<string name="common_search_for_someone">"Поиск человека"</string>
<string name="common_search_results">"Результаты поиска"</string>
@ -227,6 +232,8 @@
<string name="dialog_title_error">"Ошибка"</string>
<string name="dialog_title_success">"Успешно"</string>
<string name="dialog_title_warning">"Предупреждение"</string>
<string name="dialog_unsaved_changes_description_android">"Изменения не сохранены. Вы действительно хотите вернуться?"</string>
<string name="dialog_unsaved_changes_title">"Сохранить изменения?"</string>
<string name="error_failed_creating_the_permalink">"Не удалось создать постоянную ссылку"</string>
<string name="error_failed_loading_map">"Не удалось загрузить карту %1$s. Пожалуйста, повторите попытку позже."</string>
<string name="error_failed_loading_messages">"Не удалось загрузить сообщения"</string>
@ -252,5 +259,5 @@
<string name="screen_share_this_location_action">"Поделиться этим местоположением"</string>
<string name="screen_view_location_title">"Местоположение"</string>
<string name="settings_version_number">"Версия: %1$s (%2$s)"</string>
<string name="test_language_identifier">"en"</string>
<string name="test_language_identifier">"ru"</string>
</resources>

View file

@ -59,6 +59,7 @@
<string name="action_enter_pin">"Zadajte PIN"</string>
<string name="action_forgot_password">"Zabudnuté heslo?"</string>
<string name="action_forward">"Preposlať"</string>
<string name="action_go_back">"Ísť späť"</string>
<string name="action_invite">"Pozvať"</string>
<string name="action_invite_friends">"Pozvať ľudí"</string>
<string name="action_invite_friends_to_app">"Pozvať ľudí do %1$s"</string>
@ -86,6 +87,7 @@
<string name="action_reply_in_thread">"Odpovedať vo vlákne"</string>
<string name="action_report_bug">"Nahlásiť chybu"</string>
<string name="action_report_content">"Nahlásiť obsah"</string>
<string name="action_reset">"Obnoviť"</string>
<string name="action_retry">"Skúsiť znova"</string>
<string name="action_retry_decryption">"Opakovať dešifrovanie"</string>
<string name="action_save">"Uložiť"</string>
@ -115,6 +117,7 @@
<string name="common_audio">"Zvuk"</string>
<string name="common_blocked_users">"Blokovaní používatelia"</string>
<string name="common_bubbles">"Bubliny"</string>
<string name="common_call_invite">"Prebieha hovor (nepodporované)"</string>
<string name="common_chat_backup">"Záloha konverzácie"</string>
<string name="common_copyright">"Autorské práva"</string>
<string name="common_creating_room">"Vytváranie miestnosti…"</string>
@ -184,6 +187,8 @@
<string name="common_room">"Miestnosť"</string>
<string name="common_room_name">"Názov miestnosti"</string>
<string name="common_room_name_placeholder">"napr. názov vášho projektu"</string>
<string name="common_saved_changes">"Uložené zmeny"</string>
<string name="common_saving">"Ukladá sa"</string>
<string name="common_screen_lock">"Zámok obrazovky"</string>
<string name="common_search_for_someone">"Vyhľadať niekoho"</string>
<string name="common_search_results">"Výsledky hľadania"</string>
@ -226,6 +231,8 @@
<string name="dialog_title_error">"Chyba"</string>
<string name="dialog_title_success">"Úspech"</string>
<string name="dialog_title_warning">"Upozornenie"</string>
<string name="dialog_unsaved_changes_description_android">"Vaše zmeny neboli uložené. Naozaj sa chcete vrátiť?"</string>
<string name="dialog_unsaved_changes_title">"Uložiť zmeny?"</string>
<string name="error_failed_creating_the_permalink">"Nepodarilo sa vytvoriť trvalý odkaz"</string>
<string name="error_failed_loading_map">"%1$s nedokázal načítať mapu. Skúste to prosím neskôr."</string>
<string name="error_failed_loading_messages">"Načítanie správ zlyhalo"</string>

View file

@ -1,13 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_delete">"Radera"</string>
<string name="a11y_hide_password">"Dölj lösenord"</string>
<string name="a11y_notifications_mentions_only">"Endast omnämningar"</string>
<string name="a11y_notifications_muted">"Tystad"</string>
<string name="a11y_pause">"Pausa"</string>
<string name="a11y_play">"Spela upp"</string>
<string name="a11y_poll">"Omröstning"</string>
<string name="a11y_poll_end">"Avslutade omröstning"</string>
<string name="a11y_send_files">"Skicka filer"</string>
<string name="a11y_show_password">"Visa lösenord"</string>
<string name="a11y_user_menu">"Användarmeny"</string>
<string name="a11y_voice_message_record">"Spela in röstmeddelande."</string>
<string name="action_accept">"Godkänn"</string>
<string name="action_add_to_timeline">"Lägg till i tidslinjen"</string>
<string name="action_back">"Tillbaka"</string>
@ -65,6 +69,9 @@
<string name="action_send_message">"Skicka meddelande"</string>
<string name="action_share">"Dela"</string>
<string name="action_share_link">"Dela länk"</string>
<string name="action_sign_in_again">"Logga in igen"</string>
<string name="action_signout">"Logga ut"</string>
<string name="action_signout_anyway">"Logga ut ändå"</string>
<string name="action_skip">"Hoppa över"</string>
<string name="action_start">"Starta"</string>
<string name="action_start_chat">"Starta chat"</string>
@ -89,6 +96,7 @@
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Kryptering aktiverad"</string>
<string name="common_error">"Fel"</string>
<string name="common_everyone">"Alla"</string>
<string name="common_file">"Fil"</string>
<string name="common_file_saved_on_disk_android">"Fil sparad i Download"</string>
<string name="common_forward_message">"Vidarebefordra meddelande"</string>
@ -126,6 +134,7 @@
<string name="common_privacy_policy">"Integritetspolicy"</string>
<string name="common_reaction">"Reaktion"</string>
<string name="common_reactions">"Reaktioner"</string>
<string name="common_recovery_key">"Återställningsnyckel"</string>
<string name="common_refreshing">"Uppdaterar …"</string>
<string name="common_replying_to">"Svarar till %1$s"</string>
<string name="common_report_a_bug">"Rapportera en bugg"</string>
@ -160,6 +169,7 @@
<string name="common_verification_cancelled">"Verifiering avbruten"</string>
<string name="common_verification_complete">"Verifieringen slutförd"</string>
<string name="common_video">"Video"</string>
<string name="common_voice_message">"Röstmeddelande"</string>
<string name="common_waiting">"Väntar …"</string>
<string name="dialog_title_confirmation">"Bekräftelse"</string>
<string name="dialog_title_error">"Fel"</string>

View file

@ -111,7 +111,7 @@
<string name="common_acceptable_use_policy">"Політика прийнятного використання"</string>
<string name="common_advanced_settings">"Додаткові налаштування"</string>
<string name="common_analytics">"Аналітика"</string>
<string name="common_appearance">"Вигляд"</string>
<string name="common_appearance">"Тема"</string>
<string name="common_audio">"Аудіо"</string>
<string name="common_blocked_users">"Заблоковані користувачі"</string>
<string name="common_bubbles">"Бульбашки"</string>
@ -122,7 +122,7 @@
<string name="common_dark">"Темна"</string>
<string name="common_decryption_error">"Помилка розшифровки"</string>
<string name="common_developer_options">"Налаштування розробника"</string>
<string name="common_direct_chat">"Прямий чат"</string>
<string name="common_direct_chat">"Особистий чат"</string>
<string name="common_edited_suffix">"(відредаговано)"</string>
<string name="common_editing">"Редагування"</string>
<string name="common_emote">"* %1$s %2$s"</string>
@ -131,7 +131,7 @@
<string name="common_error">"Помилка"</string>
<string name="common_everyone">"Усі"</string>
<string name="common_failed">"Невдало"</string>
<string name="common_favourite">"Вибрані"</string>
<string name="common_favourite">"Улюблений"</string>
<string name="common_favourited">"Вибране"</string>
<string name="common_file">"Файл"</string>
<string name="common_file_saved_on_disk_android">"Файл збережений у розділі \"Завантаження\""</string>
@ -153,7 +153,7 @@
<string name="common_message">"Повідомлення"</string>
<string name="common_message_actions">"Дії з повідомленнями"</string>
<string name="common_message_layout">"Макет повідомлень"</string>
<string name="common_message_removed">"Повідомлення видалено"</string>
<string name="common_message_removed">"Повідомлення вилучено"</string>
<string name="common_modern">"Модерн"</string>
<string name="common_mute">"Вимкнути звук"</string>
<string name="common_no_results">"Немає результатів"</string>
@ -185,6 +185,7 @@
<string name="common_room">"Кімната"</string>
<string name="common_room_name">"Назва кімнати"</string>
<string name="common_room_name_placeholder">"напр., назва вашого проєкту"</string>
<string name="common_saving">"Збереження"</string>
<string name="common_screen_lock">"Блокування екрану"</string>
<string name="common_search_for_someone">"Пошук когось"</string>
<string name="common_search_results">"Результати пошуку"</string>
@ -203,7 +204,7 @@
<string name="common_success">"Успіх"</string>
<string name="common_suggestions">"Пропозиції"</string>
<string name="common_syncing">"Синхронізація"</string>
<string name="common_system">"Системні"</string>
<string name="common_system">"Системна"</string>
<string name="common_text">"Текст"</string>
<string name="common_third_party_notices">"Повідомлення третіх сторін"</string>
<string name="common_thread">"Гілка"</string>
@ -234,7 +235,7 @@
<string name="error_failed_uploading_voice_message">"Не вдалося завантажити голосове повідомлення."</string>
<string name="error_missing_location_auth_android">"%1$s не має дозволу на доступ до вашого місцезнаходження. Увімкнути доступ можна в Налаштуваннях."</string>
<string name="error_missing_location_rationale_android">"%1$s не має дозволу на доступ до вашого місцезнаходження. Увімкніть доступ нижче."</string>
<string name="error_missing_microphone_voice_rationale_android">"%1$s не має дозволу на доступ до мікрофона. Увімкнути доступ для запису голосового повідомлення."</string>
<string name="error_missing_microphone_voice_rationale_android">"%1$s не має доступу до вашого мікрофона. Надайте доступ, щоб записати голосове повідомлення."</string>
<string name="error_some_messages_have_not_been_sent">"Деякі повідомлення не були надіслані"</string>
<string name="error_unknown">"Вибачте, сталася помилка"</string>
<string name="invite_friends_rich_title">"🔐️ Приєднуйтеся до мене в %1$s"</string>

View file

@ -121,6 +121,8 @@
<string name="common_enter_your_pin">"輸入您的 PIN 碼"</string>
<string name="common_error">"錯誤"</string>
<string name="common_everyone">"所有人"</string>
<string name="common_favourite">"我的最愛"</string>
<string name="common_favourited">"我的最愛"</string>
<string name="common_file">"檔案"</string>
<string name="common_file_saved_on_disk_android">"檔案已儲存至 Downloads"</string>
<string name="common_forward_message">"訊息轉寄"</string>
@ -143,6 +145,7 @@
<string name="common_mute">"關閉通知"</string>
<string name="common_no_results">"查無結果"</string>
<string name="common_offline">"離線"</string>
<string name="common_or">"或"</string>
<string name="common_password">"密碼"</string>
<string name="common_people">"夥伴"</string>
<string name="common_permalink">"永久連結"</string>
@ -167,6 +170,7 @@
<string name="common_room_name">"聊天室名稱"</string>
<string name="common_room_name_placeholder">"範例:您的計畫名稱"</string>
<string name="common_screen_lock">"螢幕鎖定"</string>
<string name="common_search_for_someone">"搜尋使用者"</string>
<string name="common_search_results">"搜尋結果"</string>
<string name="common_security">"安全性"</string>
<string name="common_seen_by">"已讀"</string>

View file

@ -49,6 +49,7 @@
<string name="action_decline">"Decline"</string>
<string name="action_delete_poll">"Delete Poll"</string>
<string name="action_disable">"Disable"</string>
<string name="action_discard">"Discard"</string>
<string name="action_done">"Done"</string>
<string name="action_edit">"Edit"</string>
<string name="action_edit_poll">"Edit poll"</string>
@ -85,6 +86,7 @@
<string name="action_reply_in_thread">"Reply in thread"</string>
<string name="action_report_bug">"Report bug"</string>
<string name="action_report_content">"Report content"</string>
<string name="action_reset">"Reset"</string>
<string name="action_retry">"Retry"</string>
<string name="action_retry_decryption">"Retry decryption"</string>
<string name="action_save">"Save"</string>