Merge branch 'release/25.08.1'

This commit is contained in:
Jorge Martín 2025-08-06 12:42:19 +02:00
commit 499a99a5bb
228 changed files with 2757 additions and 1666 deletions

View file

@ -1,3 +1,61 @@
Changes in Element X v25.08.0
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.08.0 -->
## What's Changed
### 🐛 Bugfixes
* Fix `toPlainText` where `<ol start='n'>` tags appear by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5044
* Remove the scaling added in `Player.Listener.onVideoSizeChanged` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5055
* Make sure we clean up the pre-processed and uploaded media by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5039
* Calculate video output size taking into account portrait mode by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5068
* Prevent loop when exiting the attachments preview screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5078
* Prevent crash caused by re-release of wakelock in calls by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5077
* Make sure we display errors when we create a recovery key and it fails by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5079
* Fix crash when trying to get active notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5085
* Adapt 'change roles' screens to the new creator/owner role by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5076
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5021
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5054
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5083
### 🧱 Build
* Disable Element Call Maestro tests for the time being by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5064
### 📄 Documentation
* Grammar fixes for docs and comments by @andybalaam in https://github.com/element-hq/element-x-android/pull/5043
* Note how to switch back to the published SDK after building locally by @andybalaam in https://github.com/element-hq/element-x-android/pull/5042
### Dependency upgrades
* Update dependency io.mockk:mockk to v1.14.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5037
* Update dependency androidx.lifecycle:lifecycle-process to v2.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5036
* Update dagger to v2.57 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5038
* Update haze to v1.6.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5045
* Update dependency io.nlopez.compose.rules:detekt to v0.4.24 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5053
* Update dependency io.nlopez.compose.rules:detekt to v0.4.25 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5058
* Update coil to v3.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5063
* Update dependency io.nlopez.compose.rules:detekt to v0.4.26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5065
* Update dependency com.posthog:posthog-android to v3.20.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5067
* Update dependency com.google.firebase:firebase-bom to v34 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5061
* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5073
* Update dependency com.posthog:posthog-android to v3.20.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5087
* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5088
* Update dependency org.maplibre.gl:android-sdk to v11.13.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5093
* Update dependency androidx.test:runner to v1.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5102
* Update test.core to v1.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5104
* Update dependency androidx.test.ext:junit to v1.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5103
* Update dependency io.sentry:sentry-android to v8.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5097
### Others
* Iterate on FloatingActionButton shape and colors. by @bmarty in https://github.com/element-hq/element-x-android/pull/5033
* [a11y] Improve session verification screens by @bmarty in https://github.com/element-hq/element-x-android/pull/5017
* misc (room id) : add room id regex pattern to match new versions by @ganfra in https://github.com/element-hq/element-x-android/pull/5040
* Use lower level APIs to draw the message bubbles by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5056
* misc (store description) : update store description for fastlane by @ganfra in https://github.com/element-hq/element-x-android/pull/5060
* [a11y] Improve accessibility on avatar when creating a room. by @bmarty in https://github.com/element-hq/element-x-android/pull/5046
* Add fallback notifications from UTDs to the push history by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5047
* feature (media send queue) : enable send queue by default by @ganfra in https://github.com/element-hq/element-x-android/pull/5098
* misc : re-enable share pos by default by @ganfra in https://github.com/element-hq/element-x-android/pull/5108
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.07.1...v25.08.0
Changes in Element X v25.07.1
=============================

View file

@ -318,6 +318,7 @@ licensee {
allow("MIT")
allow("BSD-2-Clause")
allow("BSD-3-Clause")
allow("EPL-1.0")
allowUrl("https://opensource.org/licenses/MIT")
allowUrl("https://developer.android.com/studio/terms.html")
allowUrl("https://www.zetetic.net/sqlcipher/license/")

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -28,6 +29,7 @@ class LoggedInEventProcessor @Inject constructor(
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = roomMembershipObserver.updates
.filter { !it.isUserInRoom }
.distinctUntilChanged()
.onEach {
when (it.change) {
MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room)

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"خروج و ارتقا"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s دیگر از شیوه‌نامهٔ قدیمی پشتیبانی نمی‌کند. لطفاً برای ادامهٔ استفاده از کاره، خارج شده و دوباره وارد شوید."</string>
</resources>

View file

@ -0,0 +1,4 @@
Main changes in this version:
- Fixes for the room v12 changes.
- Several bug fixes, centered on media and calls.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -33,14 +33,11 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
@ -64,7 +61,6 @@ class CallScreenPresenter @AssistedInject constructor(
private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService,
private val activeRoomsHolder: ActiveRoomsHolder,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> {
@ -75,7 +71,6 @@ class CallScreenPresenter @AssistedInject constructor(
private val isInWidgetMode = callType is CallType.RoomCall
private val userAgent = userAgentProvider.provide()
private var notifiedCallStart = false
@Composable
override fun present(): CallScreenState {
@ -248,9 +243,7 @@ class CallScreenPresenter @AssistedInject constructor(
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
client.syncService().syncState
.collect { state ->
if (state == SyncState.Running) {
client.notifyCallStartIfNeeded(callType.roomId)
} else {
if (state != SyncState.Running) {
appForegroundStateService.updateIsInCallState(true)
}
}
@ -263,32 +256,6 @@ class CallScreenPresenter @AssistedInject constructor(
}
}
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
if (notifiedCallStart) return
val activeRoomForSession = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
val sendCallNotificationResult = if (activeRoomForSession != null) {
Timber.d("Notifying call start for room $roomId. Has room call: ${activeRoomForSession.info().hasRoomCall}")
activeRoomForSession.sendCallNotificationIfNeeded()
} else {
// Instantiate the room from the session and roomId and send the notification
getJoinedRoom(roomId)?.use { room ->
Timber.d("Notifying call start for room $roomId. Has room call: ${room.info().hasRoomCall}")
room.sendCallNotificationIfNeeded()
} ?: run {
Timber.w("No room found for session $sessionId and room $roomId, skipping call notification.")
return
}
}
sendCallNotificationResult.fold(
onSuccess = { notifiedCallStart = true },
onFailure = { error ->
Timber.e(error, "Failed to send call notification for room $roomId.")
}
)
}
private fun parseMessage(message: String): WidgetMessage? {
return WidgetMessageSerializer.deserialize(message).getOrNull()
}

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
@ -44,7 +45,7 @@ class DefaultCallWidgetProvider @Inject constructor(
val baseUrl = customBaseUrl ?: EMBEDDED_CALL_WIDGET_BASE_URL
val isEncrypted = room.info().isEncrypted ?: room.getUpdatedIsEncrypted().getOrThrow()
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted)
val widgetSettings = callWidgetSettingsProvider.provide(baseUrl, encrypted = isEncrypted, direct = room.isDm())
val callUrl = room.generateWidgetWebViewUrl(
widgetSettings = widgetSettings,
clientId = clientId,

View file

@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Käynnissä oleva puhelu"</string>
<string name="call_foreground_service_message_android">"Palaa puheluun napauttamalla"</string>
<string name="call_foreground_service_title_android">"☎️ Puhelu käynnissä"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ei tue Bluetooth-äänilaitteiden käyttöä tässä Android-versiossa. Valitse toinen äänilaite."</string>
<string name="screen_incoming_call_subtitle_android">"Saapuva Element Call -puhelu"</string>
</resources>

View file

@ -26,13 +26,11 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.services.analytics.api.ScreenTracker
import io.element.android.services.analytics.test.FakeScreenTracker
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule
@ -82,19 +80,12 @@ import kotlin.time.Duration.Companion.seconds
}
@Test
fun `present - with CallType RoomCall sets call as active, loads URL, runs WidgetDriver and notifies the other clients a call started`() = runTest {
val sendCallNotificationIfNeededLambda = lambdaRecorder<Result<Boolean>> { Result.success(true) }
val syncService = FakeSyncService(SyncState.Running)
val fakeRoom = FakeJoinedRoom(sendCallNotificationIfNeededResult = sendCallNotificationIfNeededLambda)
val client = FakeMatrixClient(syncService = syncService).apply {
givenGetRoomResult(A_ROOM_ID, fakeRoom)
}
fun `present - with CallType RoomCall sets call as active, loads URL and runs WidgetDriver`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val presenter = createCallScreenPresenter(
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
widgetDriver = widgetDriver,
widgetProvider = widgetProvider,
@ -116,7 +107,6 @@ import kotlin.time.Duration.Companion.seconds
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
// Wait until the WidgetDriver is loaded
skipItems(1)
@ -399,7 +389,6 @@ import kotlin.time.Duration.Companion.seconds
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
screenTracker: ScreenTracker = FakeScreenTracker(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
activeRoomsHolder: ActiveRoomsHolder = ActiveRoomsHolder(),
): CallScreenPresenter {
val userAgentProvider = object : UserAgentProvider {
override fun provide(): String {
@ -420,7 +409,6 @@ import kotlin.time.Duration.Companion.seconds
languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService,
appCoroutineScope = backgroundScope,
activeRoomsHolder = activeRoomsHolder,
)
}
}

View file

@ -0,0 +1,27 @@
import extension.setupAnvil
/*
* Copyright 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.
*/
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.changeroommemberroles.api"
}
setupAnvil()
dependencies {
implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
api(projects.libraries.usersearch.api)
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 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.features.changeroommemberroes.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint {
fun builder(parentNode: Node, buildContext: BuildContext): Builder
interface Builder {
fun room(room: JoinedRoom): Builder
fun listType(changeRoomMemberRolesListType: ChangeRoomMemberRolesListType): Builder
fun build(): Node
}
interface NodeProxy {
val roomId: RoomId
suspend fun waitForRoleChanged()
}
}
enum class ChangeRoomMemberRolesListType : NodeInputs {
SelectNewOwnersWhenLeaving,
Admins,
Moderators
}

View file

@ -0,0 +1,51 @@
import extension.setupAnvil
/*
* Copyright 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.changeroommemberroles.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupAnvil()
dependencies {
api(projects.features.changeroommemberroles.api)
implementation(projects.appnav)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
// For test fixtures used in previews
implementation(projects.libraries.previewutils)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
testImplementation(projects.services.analytics.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -1,14 +1,15 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 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.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -16,11 +17,13 @@ import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize
import kotlinx.coroutines.flow.first
@ContributesNode(RoomScope::class)
class ChangeRolesNode @AssistedInject constructor(
@ -28,31 +31,30 @@ class ChangeRolesNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
presenterFactory: ChangeRolesPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
sealed interface ListType : Parcelable {
@Parcelize
data object Admins : ListType
@Parcelize
data object Moderators : ListType
}
@Parcelize
data class Inputs(
val listType: ListType,
) : NodeInputs, Parcelable
val listType: ChangeRoomMemberRolesListType,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.run {
val role = when (inputs.listType) {
is ListType.Admins -> RoomMember.Role.Admin
is ListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.Admins -> RoomMember.Role.Admin
ChangeRoomMemberRolesListType.Moderators -> RoomMember.Role.Moderator
ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving -> RoomMember.Role.Owner(isCreator = false)
}
create(role)
}
private val stateFlow = launchMolecule { presenter.present() }
suspend fun waitForRoleChanged() {
stateFlow.first { it.savingState.isSuccess() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val state by stateFlow.collectAsState()
ChangeRolesView(
modifier = modifier,
state = state,

View file

@ -1,11 +1,11 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 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.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -22,9 +22,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.analytics.toAnalyticsMemberRole
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
@ -37,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
@ -136,8 +134,9 @@ class ChangeRolesPresenter @AssistedInject constructor(
val isModifyingAdmins = role == RoomMember.Role.Admin
val hasChanges = selectedUsers != usersWithRole
val isConfirming = saveState.value.isConfirming()
val modifyingOwners = role is RoomMember.Role.Owner
val needsConfirmation = currentUserIsAdmin && isModifyingAdmins && hasChanges && !isConfirming
val needsConfirmation = (modifyingOwners || currentUserIsAdmin && isModifyingAdmins) && hasChanges && !isConfirming
when {
needsConfirmation -> {
@ -229,3 +228,10 @@ class ChangeRolesPresenter @AssistedInject constructor(
}
}
}
internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) {
is RoomMember.Role.Owner -> RoomModeration.Role.Administrator // TODO - distinguish creator from admin
RoomMember.Role.Admin -> RoomModeration.Role.Administrator
RoomMember.Role.Moderator -> RoomModeration.Role.Moderator
RoomMember.Role.User -> RoomModeration.Role.User
}

View file

@ -5,14 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList

View file

@ -5,11 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
@ -18,6 +16,8 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.previewutils.room.aRoomMember
import io.element.android.libraries.previewutils.room.aRoomMemberList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -44,6 +44,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
aChangeRolesStateWithOwners(),
aChangeRolesStateWithOwners().copy(role = RoomMember.Role.Owner(isCreator = false)),
)
}

View file

@ -1,11 +1,11 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 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.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@ -40,7 +40,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -87,7 +86,6 @@ fun ChangeRolesView(
BackHandler(enabled = !state.isSearchActive) {
state.eventSink(ChangeRolesEvent.Exit)
}
Box(modifier = modifier) {
Scaffold(
modifier = Modifier
@ -97,9 +95,10 @@ fun ChangeRolesView(
AnimatedVisibility(visible = !state.isSearchActive) {
TopAppBar(
titleStr = when (state.role) {
is RoomMember.Role.Owner -> stringResource(R.string.screen_room_change_role_owners_title)
RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_role_administrators_title)
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_role_moderators_title)
is RoomMember.Role.Owner, RoomMember.Role.User -> error("This should never be reached")
RoomMember.Role.User -> error("This should never be reached")
},
navigationIcon = {
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
@ -188,14 +187,26 @@ fun ChangeRolesView(
when (state.savingState) {
is AsyncAction.Confirming -> {
if (state.role == RoomMember.Role.Admin) {
// Confirm adding new admins dialogs
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
when (state.role) {
is RoomMember.Role.Owner -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title),
content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description),
submitText = stringResource(CommonStrings.action_continue),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) },
destructiveSubmit = true,
)
}
is RoomMember.Role.Admin -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
else -> Unit // No confirmation needed for Moderator or User roles
}
}
is AsyncAction.Loading -> {

View file

@ -0,0 +1,82 @@
/*
* Copyright 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.features.changeroommemberroles.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.di.RoomComponentFactory
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class ChangeRoomMemberRolesRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
roomComponentFactory: RoomComponentFactory,
) : ParentNode<ChangeRoomMemberRolesRootNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget.Root),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
), DaggerComponentOwner, ChangeRoomMemberRolesEntryPoint.NodeProxy {
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
}
data class Inputs(
val joinedRoom: JoinedRoom,
val listType: ChangeRoomMemberRolesListType,
) : NodeInputs
private val inputs = inputs<Inputs>()
override val daggerComponent = roomComponentFactory.create(inputs.joinedRoom)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(ChangeRolesNode.Inputs(listType = inputs.listType)),
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(modifier = modifier, navModel = navModel)
}
override val roomId: RoomId = inputs.joinedRoom.roomId
override suspend fun waitForRoleChanged() {
waitForChildAttached<ChangeRolesNode>().waitForRoleChanged()
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright 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.features.changeroommemberroles.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultChangeRoomMemberRolesEntyPoint @Inject constructor() : ChangeRoomMemberRolesEntryPoint {
override fun builder(parentNode: Node, buildContext: BuildContext): ChangeRoomMemberRolesEntryPoint.Builder {
return object : ChangeRoomMemberRolesEntryPoint.Builder {
private lateinit var changeRoomMemberRolesListType: ChangeRoomMemberRolesListType
private lateinit var room: JoinedRoom
override fun room(room: JoinedRoom): ChangeRoomMemberRolesEntryPoint.Builder {
this.room = room
return this
}
override fun listType(changeRoomMemberRolesListType: ChangeRoomMemberRolesListType): ChangeRoomMemberRolesEntryPoint.Builder {
this.changeRoomMemberRolesListType = changeRoomMemberRolesListType
return this
}
override fun build(): Node {
return parentNode.createNode<ChangeRoomMemberRolesRootNode>(
buildContext = buildContext,
plugins = listOf(
ChangeRoomMemberRolesRootNode.Inputs(joinedRoom = room, listType = changeRoomMemberRolesListType),
)
)
}
}
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 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.features.changeroommemberroles.impl
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomMemberListDataSource @Inject constructor(
private val room: BaseRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) {
suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) {
val roomMembersState = room.membersStateFlow.value
val activeRoomMembers = roomMembersState.roomMembers()
?.filter { it.membership.isActive() }
.orEmpty()
val filteredMembers = if (query.isBlank()) {
activeRoomMembers
} else {
activeRoomMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true) ||
member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}
filteredMembers
}
}

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_change_permissions_administrators">"Admins only"</string>
<string name="screen_room_change_permissions_ban_people">"Ban people"</string>
<string name="screen_room_change_permissions_delete_messages">"Remove messages"</string>
<string name="screen_room_change_permissions_everyone">"Everyone"</string>
<string name="screen_room_change_permissions_invite_people">"Invite people and accept requests to join"</string>
<string name="screen_room_change_permissions_member_moderation">"Member moderation"</string>
<string name="screen_room_change_permissions_messages_and_content">"Messages and content"</string>
<string name="screen_room_change_permissions_moderators">"Admins and moderators"</string>
<string name="screen_room_change_permissions_remove_people">"Remove people and decline requests to join"</string>
<string name="screen_room_change_permissions_room_avatar">"Change room avatar"</string>
<string name="screen_room_change_permissions_room_details">"Room details"</string>
<string name="screen_room_change_permissions_room_name">"Change room name"</string>
<string name="screen_room_change_permissions_room_topic">"Change room topic"</string>
<string name="screen_room_change_permissions_send_messages">"Send messages"</string>
<string name="screen_room_change_role_administrators_title">"Edit Admins"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"You will not be able to undo this action. You are promoting the user to have the same power level as you."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Add Admin?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"You will not be able to undo this action. You are transferring the ownership to the selected users. Once you leave this will be permanent."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Transfer ownership?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Demote"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Pending)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(Pending)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Admins automatically have moderator privileges"</string>
<string name="screen_room_change_role_moderators_owner_section_footer">"Owners automatically have admin privileges."</string>
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
<string name="screen_room_change_role_owners_title">"Choose Owners"</string>
<string name="screen_room_change_role_section_administrators">"Admins"</string>
<string name="screen_room_change_role_section_moderators">"Moderators"</string>
<string name="screen_room_change_role_section_users">"Members"</string>
<string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
<string name="screen_room_member_list_banned_empty">"There are no banned users in this room."</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d people"</item>
</plurals>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ban from room"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Only remove member"</string>
<string name="screen_room_member_list_manage_member_unban_action">"Unban"</string>
<string name="screen_room_member_list_manage_member_unban_message">"They will be able to join this room again if invited."</string>
<string name="screen_room_member_list_manage_member_unban_title">"Unban user"</string>
<string name="screen_room_member_list_mode_banned">"Banned"</string>
<string name="screen_room_member_list_mode_members">"Members"</string>
<string name="screen_room_member_list_pending_header_title">"Pending"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Moderator"</string>
<string name="screen_room_member_list_role_owner">"Owner"</string>
<string name="screen_room_member_list_room_members_header_title">"Room members"</string>
<string name="screen_room_member_list_unbanning_user">"Unbanning %1$s"</string>
<string name="screen_room_roles_and_permissions_admins">"Admins"</string>
<string name="screen_room_roles_and_permissions_admins_and_owners">"Admins and owners"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Change my role"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Demote to member"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Demote to moderator"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Member moderation"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Messages and content"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderators"</string>
<string name="screen_room_roles_and_permissions_owners">"Owners"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Permissions"</string>
<string name="screen_room_roles_and_permissions_reset">"Reset permissions"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Once you reset permissions, you will lose the current settings."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Reset permissions?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Roles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Room details"</string>
<string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string>
</resources>

View file

@ -1,19 +1,17 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 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.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@ -28,14 +26,18 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
import io.element.android.libraries.previewutils.room.aRoomMemberList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.collections.plus
class ChangeRolesPresenterTest {
@Test
@ -430,6 +432,44 @@ class ChangeRolesPresenterTest {
}
}
@Test
fun `present - Save will ask for confirmation before assigning new owners`() = runTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.success(Unit) },
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(
aRoomInfo(
roomCreators = listOf(sessionId),
roomPowerLevels = roomPowerLevelsWithRoles(
A_USER_ID to RoomMember.Role.Owner(isCreator = false),
A_USER_ID_2 to RoomMember.Role.Admin,
)
)
)
}
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.Owner(isCreator = false),
room = room,
analyticsService = analyticsService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.selectedUsers).hasSize(1)
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2)))
awaitItem().eventSink(ChangeRolesEvent.Save)
assertThat(awaitItem().savingState.isConfirming()).isTrue()
}
}
@Test
fun `present - Save will just save the changes if the current user is a room creator and the selected users are not`() = runTest {
val analyticsService = FakeAnalyticsService()
@ -510,9 +550,16 @@ class ChangeRolesPresenterTest {
)
}
private fun roomPowerLevelsWithRoles(vararg pairs: Pair<UserId, RoomMember.Role>): RoomPowerLevels {
return RoomPowerLevels(
values = defaultRoomPowerLevelValues(),
users = pairs.associate { (userId, role) -> userId to role.powerLevel }.toPersistentMap()
)
}
private fun TestScope.createChangeRolesPresenter(
role: RoomMember.Role = RoomMember.Role.Admin,
room: FakeJoinedRoom = FakeJoinedRoom(),
room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
): ChangeRolesPresenter {

View file

@ -1,11 +1,11 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 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.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@ -54,20 +54,6 @@ class ChangeRolesViewTest {
assertThat(exception).isNotNull()
}
@Test
fun `passing an 'Owner' role throws an exception`() {
val exception = runCatchingExceptions {
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.Owner(isCreator = true),
eventSink = EnsureNeverCalledWithParam(),
),
)
}.exceptionOrNull()
assertThat(exception).isNotNull()
}
@Test
fun `back key - with search active toggles the search`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
@ -192,6 +178,23 @@ class ChangeRolesViewTest {
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
}
@Test
fun `save owners confirmation dialog - continue saves the changes`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.Owner(isCreator = false),
isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
}
@Test
fun `save confirmation dialog - cancel removes the dialog`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
package io.element.android.features.changeroommemberroles.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.RoomMember

View file

@ -54,6 +54,7 @@ dependencies {
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(projects.features.reportroom.api)
implementation(projects.features.changeroommemberroles.api)
api(projects.features.home.api)
testImplementation(libs.androidx.compose.ui.test.junit)

View file

@ -11,7 +11,11 @@ import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -19,31 +23,43 @@ import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.home.impl.components.RoomListMenuAction
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class HomeFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val matrixClient: MatrixClient,
private val presenter: HomePresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService,
@ -51,6 +67,8 @@ class HomeFlowNode @AssistedInject constructor(
private val directLogoutView: DirectLogoutView,
private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val leaveRoomRenderer: LeaveRoomRenderer,
) : BaseFlowNode<HomeFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@ -59,12 +77,25 @@ class HomeFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins
) {
init {
private val stateFlow = launchMolecule { presenter.present() }
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home))
}
)
whenChildAttached { commonLifecycle: Lifecycle,
changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy ->
commonLifecycle.coroutineScope.launch {
changeRoomMemberRolesNode.waitForRoleChanged()
withContext(NonCancellable) {
backstack.pop()
onNewOwnersSelected(changeRoomMemberRolesNode.roomId)
}
}
}
}
sealed interface NavTarget : Parcelable {
@ -76,6 +107,9 @@ class HomeFlowNode @AssistedInject constructor(
@Parcelize
data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget
@Parcelize
data class SelectNewOwnersWhenLeavingRoom(val roomId: RoomId) : NavTarget
}
private fun onRoomClick(roomId: RoomId) {
@ -121,11 +155,18 @@ class HomeFlowNode @AssistedInject constructor(
}
}
private fun onSelectNewOwnersWhenLeavingRoom(roomId: RoomId) {
backstack.push(NavTarget.SelectNewOwnersWhenLeavingRoom(roomId))
}
private fun onNewOwnersSelected(roomId: RoomId) {
stateFlow.value.roomListState.eventSink(RoomListEvents.LeaveRoom(roomId, needsConfirmation = false))
}
fun rootNode(buildContext: BuildContext): Node {
return node(buildContext) { modifier ->
val state = presenter.present()
val state by stateFlow.collectAsState()
val activity = requireNotNull(LocalActivity.current)
HomeView(
homeState = state,
onRoomClick = this::onRoomClick,
@ -138,15 +179,22 @@ class HomeFlowNode @AssistedInject constructor(
onReportRoomClick = this::onReportRoomClick,
onDeclineInviteAndBlockUser = this::onDeclineInviteAndBlockUserClick,
modifier = modifier,
) {
acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = this::onRoomClick,
onDeclineInviteSuccess = { },
modifier = Modifier
)
}
acceptDeclineInviteView = {
acceptDeclineInviteView.Render(
state = state.roomListState.acceptDeclineInviteState,
onAcceptInviteSuccess = this::onRoomClick,
onDeclineInviteSuccess = { },
modifier = Modifier
)
},
leaveRoomView = {
leaveRoomRenderer.Render(
state = state.roomListState.leaveRoomState,
onSelectNewOwners = this::onSelectNewOwnersWhenLeavingRoom,
modifier = Modifier
)
}
)
directLogoutView.Render(state.directLogoutState)
}
}
@ -160,6 +208,13 @@ class HomeFlowNode @AssistedInject constructor(
return when (navTarget) {
is NavTarget.ReportRoom -> reportRoomEntryPoint.createNode(this, buildContext, navTarget.roomId)
is NavTarget.DeclineInviteAndBlockUser -> declineInviteAndBlockUserEntryPoint.createNode(this, buildContext, navTarget.inviteData)
is NavTarget.SelectNewOwnersWhenLeavingRoom -> {
val room = runBlocking { matrixClient.getJoinedRoom(navTarget.roomId) } ?: error("Room ${navTarget.roomId} not found")
changeRoomMemberRolesEntryPoint.builder(this, buildContext)
.room(room)
.listType(ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving)
.build()
}
NavTarget.Root -> rootNode(buildContext)
}
}

View file

@ -49,7 +49,6 @@ import io.element.android.features.home.impl.roomlist.RoomListDeclineInviteMenu
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.features.home.impl.roomlist.RoomListState
import io.element.android.features.home.impl.search.RoomListSearchView
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -78,8 +77,9 @@ fun HomeView(
onMenuActionClick: (RoomListMenuAction) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit,
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
modifier: Modifier = Modifier,
leaveRoomView: @Composable () -> Unit,
) {
val state: RoomListState = homeState.roomListState
val coroutineScope = rememberCoroutineScope()
@ -108,7 +108,7 @@ fun HomeView(
)
}
LeaveRoomView(state = state.leaveRoomState)
leaveRoomView()
HomeScaffold(
state = homeState,
@ -304,5 +304,6 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
onMenuActionClick = {},
onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {},
leaveRoomView = {}
)
}

View file

@ -60,7 +60,7 @@ fun RoomListContextMenu(
},
onLeaveRoomClick = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId))
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true))
},
onFavoriteChange = { isFavorite ->
eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite))

View file

@ -24,7 +24,7 @@ sealed interface RoomListEvents {
sealed interface ContextMenuEvents : RoomListEvents
data object HideContextMenu : ContextMenuEvents
data class LeaveRoom(val roomId: RoomId) : ContextMenuEvents
data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : ContextMenuEvents
data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents
data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents

View file

@ -31,7 +31,7 @@ import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent.ShowConfirmation
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@ -127,7 +127,9 @@ class RoomListPresenter @Inject constructor(
is RoomListEvents.HideContextMenu -> {
contextMenu.value = RoomListState.ContextMenu.Hidden
}
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(ShowConfirmation(event.roomId))
is RoomListEvents.LeaveRoom -> {
leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(event.roomId, needsConfirmation = event.needsConfirmation))
}
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)

View file

@ -18,8 +18,8 @@ import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -70,6 +70,12 @@ internal fun aRoomListState(
eventSink = eventSink,
)
internal fun aLeaveRoomState(
eventSink: (LeaveRoomEvent) -> Unit = {}
) = object : LeaveRoomState {
override val eventSink: (LeaveRoomEvent) -> Unit = eventSink
}
internal fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Dit nøglelager er ikke synkroniseret"</string>
<string name="full_screen_intent_banner_message">"For at sikre, at du aldrig går glip af et vigtigt opkald, skal du ændre dine indstillinger til at tillade underretninger i fuld skærm, når din telefon er låst."</string>
<string name="full_screen_intent_banner_title">"Gør din opkaldsoplevelse bedre"</string>
<string name="screen_home_tab_chats">"Samtaler"</string>
<string name="screen_home_tab_spaces">"Klynger"</string>
<string name="screen_invites_decline_chat_message">"Er du sikker på, at du vil afvise invitationen til at deltage i %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Afvis invitation"</string>
<string name="screen_invites_decline_direct_chat_message">"Er du sikker på, at du vil afvise denne private samtale med %1$s?"</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Disable battery optimization for this app, to make sure all notifications are received."</string>
<string name="banner_battery_optimization_submit_android">"Disable optimization"</string>
<string name="screen_roomlist_filter_favourites">"Favorites"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"You can add a chat to your favorites in the chat settings.
For now, you can deselect filters in order to see your other chats"</string>

View file

@ -1,10 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"از کار انداختن بهینه‌سازی باتری برای این کاره برای اطمینان از گرفتن همهٔ آگاهی‌ها."</string>
<string name="banner_battery_optimization_submit_android">"از کار انداختن بهینه سازی"</string>
<string name="banner_battery_optimization_title_android">"آگاهی‌ها نمی‌رسند؟"</string>
<string name="banner_set_up_recovery_content">"بازگردانی تاریخچهٔ پیام‌ها و هویت رمزنگاشته‌تان با کلید بازیابی در صورت از دست دادن همهٔ افزاره‌های موجودتان."</string>
<string name="banner_set_up_recovery_submit">"برپایی بازیابی"</string>
<string name="banner_set_up_recovery_title">"برپایی بازیابی"</string>
<string name="confirm_recovery_key_banner_primary_button_title">"ورود کلید بازیابیتان"</string>
<string name="confirm_recovery_key_banner_title">"ذخیره‌ساز کلیدتان از هم‌گام بودن در آمده"</string>
<string name="full_screen_intent_banner_title">"بهبود تجریهٔ تماستان"</string>
<string name="screen_home_tab_chats">"گپ‌ها"</string>
<string name="screen_home_tab_spaces">"فضاها"</string>
<string name="screen_invites_decline_chat_message">"مطمئنید که می‌خواهید دعوت پیوستن به %1$s را رد کنید؟"</string>
<string name="screen_invites_decline_chat_title">"رد دعوت"</string>
<string name="screen_invites_decline_direct_chat_message">"مطمئنید که می‌خواهید این گپ خصوصی با %1$s را رد کنید؟"</string>
@ -34,6 +40,6 @@
<string name="screen_roomlist_main_space_title">"گپ‌ها"</string>
<string name="screen_roomlist_mark_as_read">"علامت‌گذاری به عنوان خوانده شده"</string>
<string name="screen_roomlist_mark_as_unread">"نشان به ناخوانده"</string>
<string name="session_verification_banner_message">"به نظر می رسد از دستگاه جدیدی استفاده می کنید. برای دسترسی به پیام های رمزگذاری شده خود، با دستگاه دیگری این دستگاه را تأیید کنید."</string>
<string name="session_verification_banner_message">"گویا از افزاره‌ای جدید استفاده می‌کنید. تأیید با افزاره‌ای دیگر برای دسترسی به پیام‌های رمزنگاری شده‌تان."</string>
<string name="session_verification_banner_title">"تأیید کنید که خودتانید"</string>
</resources>

View file

@ -2,6 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Ota tämän sovelluksen akunkäytön optimointi pois käytöstä varmistaaksesi, että kaikki ilmoitukset tulevat perille."</string>
<string name="banner_battery_optimization_submit_android">"Ota optimointi pois käytöstä"</string>
<string name="banner_battery_optimization_title_android">"Eikö ilmoitukset tule perille?"</string>
<string name="banner_set_up_recovery_content">"Palauta kryptografinen identiteettisi ja viestihistoriasi palautusavaimella, mikäli menetät pääsyn kaikkiin laitteisiisi."</string>
<string name="banner_set_up_recovery_submit">"Ota palautus käyttöön"</string>
<string name="banner_set_up_recovery_title">"Ota palautus käyttöön tilisi suojaamiseksi"</string>
@ -20,6 +21,7 @@
<string name="screen_migration_message">"Tämä on kertaluonteinen prosessi, kiitos odottamisesta."</string>
<string name="screen_migration_title">"Tiliä määritetään."</string>
<string name="screen_roomlist_a11y_create_message">"Luo uusi keskustelu tai huone"</string>
<string name="screen_roomlist_clear_filters">"Tyhjennä suodattimet"</string>
<string name="screen_roomlist_empty_message">"Aloita lähettämällä viesti jollekin."</string>
<string name="screen_roomlist_empty_title">"Sinulla ei ole vielä keskusteluja."</string>
<string name="screen_roomlist_filter_favourites">"Suosikit"</string>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_submit_android">"Optimalisatie uitschakelen"</string>
<string name="banner_set_up_recovery_content">"Herstel je cryptografische identiteit en berichtengeschiedenis met een herstelsleutel voor als je al je bestaande apparaten kwijt bent."</string>
<string name="banner_set_up_recovery_submit">"Herstelmogelijkheid instellen"</string>
<string name="banner_set_up_recovery_title">"Herstel instellen om je account te beschermen"</string>

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Ваше сховище ключів не синхронізовано"</string>
<string name="full_screen_intent_banner_message">"Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."</string>
<string name="full_screen_intent_banner_title">"Покращуйте досвід дзвінків"</string>
<string name="screen_home_tab_chats">"Бесіди"</string>
<string name="screen_home_tab_spaces">"Простори"</string>
<string name="screen_invites_decline_chat_message">"Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Відхилити запрошення"</string>
<string name="screen_invites_decline_direct_chat_message">"Ви дійсно хочете відмовитися від приватної бесіди з %1$s?"</string>
@ -21,6 +23,7 @@
<string name="screen_migration_message">"Це одноразовий процес, дякую за очікування."</string>
<string name="screen_migration_title">"Налаштування облікового запису."</string>
<string name="screen_roomlist_a11y_create_message">"Створити нову розмову або кімнату"</string>
<string name="screen_roomlist_clear_filters">"Очистити фільтри"</string>
<string name="screen_roomlist_empty_message">"Почніть з обміну повідомленнями з кимось."</string>
<string name="screen_roomlist_empty_title">"Ще немає бесід."</string>
<string name="screen_roomlist_filter_favourites">"Обране"</string>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Disable battery optimization for this app, to make sure all notifications are received."</string>
<string name="banner_battery_optimization_submit_android">"Disable optimization"</string>
<string name="banner_battery_optimization_content_android">"Disable battery optimisation for this app, to make sure all notifications are received."</string>
<string name="banner_battery_optimization_submit_android">"Disable optimisation"</string>
<string name="banner_battery_optimization_title_android">"Notifications not arriving?"</string>
<string name="banner_set_up_recovery_content">"Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."</string>
<string name="banner_set_up_recovery_submit">"Set up recovery"</string>

View file

@ -73,7 +73,7 @@ class RoomListContextMenuTest {
eventsRecorder.assertList(
listOf(
RoomListEvents.HideContextMenu,
RoomListEvents.LeaveRoom(contextMenu.roomId),
RoomListEvents.LeaveRoom(contextMenu.roomId, needsConfirmation = true),
)
)
}

View file

@ -27,7 +27,6 @@ import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteS
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
@ -319,8 +318,8 @@ class RoomListPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID))
leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
leaveRoomEventsRecorder.assertSingle(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
cancelAndIgnoreRemainingEvents()
}
}

View file

@ -289,7 +289,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onMenuActionClick = onMenuActionClick,
onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,
onReportRoomClick = onReportRoomClick,
acceptDeclineInviteView = { },
acceptDeclineInviteView = {},
leaveRoomView = {},
)
}
}

View file

@ -56,15 +56,11 @@ class AcceptDeclineInvitePresenter @Inject constructor(
)
}
}
is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> {
declinedAction.value = AsyncAction.Uninitialized
}
is InternalAcceptDeclineInviteEvents.DismissAcceptError -> {
is InternalAcceptDeclineInviteEvents.ClearAcceptActionState -> {
acceptedAction.value = AsyncAction.Uninitialized
}
is InternalAcceptDeclineInviteEvents.DismissDeclineError -> {
is InternalAcceptDeclineInviteEvents.ClearDeclineActionState -> {
declinedAction.value = AsyncAction.Uninitialized
}
}

View file

@ -35,9 +35,12 @@ fun AcceptDeclineInviteView(
Box(modifier = modifier) {
AsyncActionView(
async = state.acceptAction,
onSuccess = onAcceptInviteSuccess,
onSuccess = { roomId ->
state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState)
onAcceptInviteSuccess(roomId)
},
onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.DismissAcceptError)
state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState)
},
errorTitle = {
stringResource(CommonStrings.common_something_went_wrong)
@ -52,9 +55,12 @@ fun AcceptDeclineInviteView(
)
AsyncActionView(
async = state.declineAction,
onSuccess = onDeclineInviteSuccess,
onSuccess = { roomId ->
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState)
onDeclineInviteSuccess(roomId)
},
onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError)
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState)
},
errorTitle = {
stringResource(CommonStrings.common_something_went_wrong)
@ -78,7 +84,7 @@ fun AcceptDeclineInviteView(
)
},
onDismissClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite)
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState)
}
)
}

View file

@ -10,7 +10,6 @@ package io.element.android.features.invite.impl.acceptdecline
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents {
data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents
data object DismissAcceptError : InternalAcceptDeclineInviteEvents
data object DismissDeclineError : InternalAcceptDeclineInviteEvents
data object ClearAcceptActionState : InternalAcceptDeclineInviteEvents
data object ClearDeclineActionState : InternalAcceptDeclineInviteEvents
}

View file

@ -56,7 +56,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
state.eventSink(
InternalAcceptDeclineInviteEvents.CancelDeclineInvite
InternalAcceptDeclineInviteEvents.ClearDeclineActionState
)
}
awaitItem().also { state ->
@ -90,7 +90,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(
InternalAcceptDeclineInviteEvents.DismissDeclineError
InternalAcceptDeclineInviteEvents.ClearDeclineActionState
)
}
awaitItem().also { state ->
@ -154,7 +154,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state ->
assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(
InternalAcceptDeclineInviteEvents.DismissAcceptError
InternalAcceptDeclineInviteEvents.ClearAcceptActionState
)
}
awaitItem().also { state ->

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Deltag i rummet"</string>
<string name="screen_join_room_join_restricted_message">"Du skal muligvis være inviteret eller være medlem af en klynge for at deltage."</string>
<string name="screen_join_room_knock_action">"Send anmodning om at deltage"</string>
<string name="screen_join_room_knock_message_characters_count">"Tilladte tegn %1$d af %2$d"</string>
<string name="screen_join_room_knock_message_description">"Besked (valgfrit)"</string>
<string name="screen_join_room_knock_sent_description">"Du vil modtage en invitation til at deltage i rummet, hvis din anmodning accepteres."</string>
<string name="screen_join_room_knock_sent_title">"Anmodning om at deltage sendt"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Liity huoneeseen"</string>
<string name="screen_join_room_join_restricted_message">"Saatat tarvita kutsun tai olla tilan jäsen, jotta voit liittyä."</string>
<string name="screen_join_room_knock_action">"Lähetä liittymispyyntö"</string>
<string name="screen_join_room_knock_message_characters_count">"Sallitut merkit %1$d / %2$d"</string>
<string name="screen_join_room_knock_message_description">"Viesti (valinnainen)"</string>
<string name="screen_join_room_knock_sent_description">"Saat kutsun liittyä huoneeseen, jos pyyntösi hyväksytään."</string>
<string name="screen_join_room_knock_sent_title">"Liittymispyyntö lähetetty"</string>

View file

@ -12,19 +12,20 @@
<string name="screen_join_room_decline_and_block_alert_title">"Refuser linvitation et bloquer"</string>
<string name="screen_join_room_decline_and_block_button_title">"Refuser et bloquer"</string>
<string name="screen_join_room_fail_message">"Rejoindre le salon a échoué."</string>
<string name="screen_join_room_fail_reason">"Ce salon est accessible uniquement sur invitation ou il peut y avoir des restrictions daccès au niveau du Space."</string>
<string name="screen_join_room_fail_reason">"Ce salon est accessible uniquement sur invitation ou il peut y avoir des restrictions daccès au niveau de lespace."</string>
<string name="screen_join_room_forget_action">"Oublier ce salon"</string>
<string name="screen_join_room_invite_required_message">"Vous avez besoin dune invitation pour rejoindre ce salon"</string>
<string name="screen_join_room_join_action">"Rejoindre"</string>
<string name="screen_join_room_join_restricted_message">"Il est possible que vous deviez être invité ou être membre dun Space pour pouvoir rejoindre le salon."</string>
<string name="screen_join_room_join_restricted_message">"Il est possible que vous deviez être invité ou être membre dun Espace pour pouvoir rejoindre le salon."</string>
<string name="screen_join_room_knock_action">"Demander à joindre"</string>
<string name="screen_join_room_knock_message_characters_count">"Caractères autorisés %1$d sur %2$d"</string>
<string name="screen_join_room_knock_message_description">"Message (facultatif)"</string>
<string name="screen_join_room_knock_sent_description">"Vous recevrez une invitation à rejoindre le salon si votre demande est acceptée."</string>
<string name="screen_join_room_knock_sent_title">"Demande de rejoindre le salon envoyée"</string>
<string name="screen_join_room_loading_alert_message">"Impossible dafficher laperçu du salon. Cela peut être dû à des problèmes de réseau ou de serveur."</string>
<string name="screen_join_room_loading_alert_title">"Impossible dafficher laperçu de ce salon"</string>
<string name="screen_join_room_space_not_supported_description">"Les Spaces ne sont pas encore pris en charge par %1$s. Vous pouvez voir les Spaces sur le Web."</string>
<string name="screen_join_room_space_not_supported_title">"Les Spaces ne sont pas encore pris en charge"</string>
<string name="screen_join_room_space_not_supported_description">"Les Espaces ne sont pas encore pris en charge par %1$s. Vous pouvez voir les Espaces sur le Web."</string>
<string name="screen_join_room_space_not_supported_title">"Les Espaces ne sont pas encore pris en charge"</string>
<string name="screen_join_room_subtitle_knock">"Cliquez ci-dessous et un administrateur sera prévenu. Une fois votre demande approuvée, pour pourrez rejoindre la discussion."</string>
<string name="screen_join_room_subtitle_no_preview">"Vous devez être un membre du salon pour pouvoir lire lhistorique des messages."</string>
<string name="screen_join_room_title_knock">"Vous souhaitez rejoindre ce salon ?"</string>

View file

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_ban_reason">"Reden: %1$s."</string>
<string name="screen_join_room_cancel_knock_action">"Verzoek annuleren"</string>
<string name="screen_join_room_forget_action">"Deze kamer vergeten"</string>
<string name="screen_join_room_join_action">"Toetreden tot de kamer"</string>
<string name="screen_join_room_knock_action">"Klop om deel te nemen"</string>
<string name="screen_join_room_knock_message_description">"Bericht (optioneel)"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Dołącz do pokoju"</string>
<string name="screen_join_room_join_restricted_message">"Aby dołączyć, musisz uzyskać zaproszenie lub być członkiem danej przestrzeni."</string>
<string name="screen_join_room_knock_action">"Wyślij prośbę o dołączenie"</string>
<string name="screen_join_room_knock_message_characters_count">"Dozwolone znaki %1$d z %2$d"</string>
<string name="screen_join_room_knock_message_description">"Wiadomość (opcjonalne)"</string>
<string name="screen_join_room_knock_sent_description">"Otrzymasz zaproszenie dołączenia do pokoju, jeśli prośba zostanie zaakceptowana."</string>
<string name="screen_join_room_knock_sent_title">"Wysłano prośbę o dołączenie"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Приєднатися до кімнати"</string>
<string name="screen_join_room_join_restricted_message">"Можливо, вам знадобиться отримати запрошення або стати учасником простору, щоб приєднатися."</string>
<string name="screen_join_room_knock_action">"Постукати, щоб приєднатися"</string>
<string name="screen_join_room_knock_message_characters_count">"Дозволені символи %1$d з %2$d"</string>
<string name="screen_join_room_knock_message_description">"Повідомлення (необов\'язково)"</string>
<string name="screen_join_room_knock_sent_description">"Ви отримаєте запрошення приєднатися до кімнати, якщо ваш запит буде прийнятий."</string>
<string name="screen_join_room_knock_sent_title">"Запит на приєднання надіслано"</string>

View file

@ -14,7 +14,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrix.api)
}

View file

@ -9,9 +9,6 @@ package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveRoomEvent {
data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent
data object HideConfirmation : LeaveRoomEvent
data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent
data object HideError : LeaveRoomEvent
interface LeaveRoomEvent {
data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : LeaveRoomEvent
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 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.features.leaveroom.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
interface LeaveRoomRenderer {
@Composable
fun Render(
state: LeaveRoomState,
onSelectNewOwners: (RoomId) -> Unit,
modifier: Modifier,
)
}

View file

@ -7,29 +7,6 @@
package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId
data class LeaveRoomState(
val confirmation: Confirmation,
val progress: Progress,
val error: Error,
val eventSink: (LeaveRoomEvent) -> Unit,
) {
sealed interface Confirmation {
data object Hidden : Confirmation
data class Dm(val roomId: RoomId) : Confirmation
data class Generic(val roomId: RoomId) : Confirmation
data class PrivateRoom(val roomId: RoomId) : Confirmation
data class LastUserInRoom(val roomId: RoomId) : Confirmation
}
sealed interface Progress {
data object Hidden : Progress
data object Shown : Progress
}
sealed interface Error {
data object Hidden : Error
data object Shown : Error
}
interface LeaveRoomState {
val eventSink: (LeaveRoomEvent) -> Unit
}

View file

@ -1,66 +0,0 @@
/*
* Copyright 2023, 2024 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.features.leaveroom.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
override val values: Sequence<LeaveRoomState>
get() = sequenceOf(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Generic(roomId = A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.PrivateRoom(roomId = A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.LastUserInRoom(roomId = A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Shown,
error = LeaveRoomState.Error.Hidden,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Shown,
),
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Dm(roomId = A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
)
}
private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
fun aLeaveRoomState(
confirmation: LeaveRoomState.Confirmation = LeaveRoomState.Confirmation.Hidden,
progress: LeaveRoomState.Progress = LeaveRoomState.Progress.Hidden,
error: LeaveRoomState.Error = LeaveRoomState.Error.Hidden,
eventSink: (LeaveRoomEvent) -> Unit = {},
) = LeaveRoomState(
confirmation = confirmation,
progress = progress,
error = error,
eventSink = eventSink,
)

View file

@ -1,124 +0,0 @@
/*
* Copyright 2023, 2024 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.features.leaveroom.api
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LeaveRoomView(
state: LeaveRoomState
) {
LeaveRoomConfirmationDialog(state)
LeaveRoomProgressDialog(state)
LeaveRoomErrorDialog(state)
}
@Composable
private fun LeaveRoomConfirmationDialog(
state: LeaveRoomState,
) {
when (state.confirmation) {
is LeaveRoomState.Confirmation.Hidden -> {}
is LeaveRoomState.Confirmation.Dm -> LeaveRoomConfirmationDialog(
text = R.string.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId,
isDm = false,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = R.string.leave_room_alert_private_subtitle,
roomId = state.confirmation.roomId,
isDm = false,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = R.string.leave_room_alert_empty_subtitle,
roomId = state.confirmation.roomId,
isDm = false,
eventSink = state.eventSink,
)
is LeaveRoomState.Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = R.string.leave_room_alert_subtitle,
roomId = state.confirmation.roomId,
isDm = false,
eventSink = state.eventSink,
)
}
}
@Composable
private fun LeaveRoomConfirmationDialog(
@StringRes text: Int,
roomId: RoomId,
isDm: Boolean,
eventSink: (LeaveRoomEvent) -> Unit,
) {
ConfirmationDialog(
title = stringResource(if (isDm) CommonStrings.action_leave_conversation else CommonStrings.action_leave_room),
content = stringResource(text),
submitText = stringResource(CommonStrings.action_leave),
onSubmitClick = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) },
onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) },
)
}
@Composable
private fun LeaveRoomProgressDialog(
state: LeaveRoomState,
) {
when (state.progress) {
is LeaveRoomState.Progress.Hidden -> {}
is LeaveRoomState.Progress.Shown -> ProgressDialog(
text = stringResource(CommonStrings.common_leaving_room),
)
}
}
@Composable
private fun LeaveRoomErrorDialog(
state: LeaveRoomState,
) {
when (state.error) {
is LeaveRoomState.Error.Hidden -> {}
is LeaveRoomState.Error.Shown -> ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onSubmit = { state.eventSink(LeaveRoomEvent.HideError) }
)
}
}
@PreviewsDayNight
@Composable
internal fun LeaveRoomViewPreview(
@PreviewParameter(LeaveRoomStateProvider::class) state: LeaveRoomState
) = ElementPreview {
Box(
modifier = Modifier.size(300.dp, 300.dp),
propagateMinConstraints = true,
) {
LeaveRoomView(state = state)
}
}

View file

@ -3,5 +3,8 @@
<string name="leave_conversation_alert_subtitle">"Er du sikker på, at du vil forlade denne samtale? Denne samtale er ikke offentlig, og du kan ikke deltage igen uden en invitation."</string>
<string name="leave_room_alert_empty_subtitle">"Er du sikker på, at du vil forlade dette rum? Du er den eneste person her. Hvis du går, vil ingen kunne tilslutte sig det i fremtiden, heller ikke dig."</string>
<string name="leave_room_alert_private_subtitle">"Er du sikker på, at du vil forlade dette rum? Rummet er ikke offentligt, så du vil ikke kunne deltage igen uden en invitation."</string>
<string name="leave_room_alert_select_new_owner_action">"Vælg ejere"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Du er den eneste ejer af dette rum. Du skal overføre ejerskabet til en anden, før du forlader rummet."</string>
<string name="leave_room_alert_select_new_owner_title">"Overdrag ejerskab"</string>
<string name="leave_room_alert_subtitle">"Er du sikker på, at du ønsker at forlade rummet?"</string>
</resources>

View file

@ -3,5 +3,8 @@
<string name="leave_conversation_alert_subtitle">"Kas sa oled kindel, et soovid sellest vestlusest lahkuda? See vestlus pole avalik ja uuesti liitumiseks vajad kutset."</string>
<string name="leave_room_alert_empty_subtitle">"Kas sa oled kindel, et soovid sellest jututoast lahkuda? Sa oled siin viimane osaleja ja peale sinu lahkumist ei saa keegi enam liituda, isegi sina mitte."</string>
<string name="leave_room_alert_private_subtitle">"Kas sa oled kindel, et soovid sellest jututoast lahkuda? See jututuba pole avalik ja uuesti liitumiseks vajad kutset."</string>
<string name="leave_room_alert_select_new_owner_action">"Vali omanikud"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Sa oled selle jututoa ainus omanik. Enne jututoast lahkumist pead omandi üle andma kellelegi teisele."</string>
<string name="leave_room_alert_select_new_owner_title">"Anna omand üle"</string>
<string name="leave_room_alert_subtitle">"Kas sa oled kindel, et soovid sellest jututoast lahkuda?"</string>
</resources>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_room_alert_empty_subtitle">"آیا مطمئن هستید که می خواهید این اتاق را ترک کنید؟ شما تنها کسی هستید که اینجا هستید. اگر ترک کنید، هیچ کس نمی تواند در آینده به آن بپیوندد.از جمله خود شما."</string>
<string name="leave_room_alert_private_subtitle">"آیا مطمئن هستید که می خواهید از این اتاق خارج شوید؟ این اتاق عمومی نیست و نمی‌توانید بدون دعوت دوباره بپیوندید."</string>
<string name="leave_room_alert_subtitle">"آیا مطمئن هستید که می خواهید اتاق را ترک کنید؟"</string>
<string name="leave_room_alert_empty_subtitle">"مطمئنید که می‌خواهید این اتاق را ترک کنید؟ تنها فرد این‌جا هستید. در صورت ترک، هیچ‌کسی از جمله خودتان در آینده نخواهد توانست به آن بپیوندد."</string>
<string name="leave_room_alert_private_subtitle">"مطمئنید که می‌خواهید این اتاق را ترک کنید؟ این اتاق عمومی نبوده قادر نخواهید بود بدون دعوت دوباره بپیوندید."</string>
<string name="leave_room_alert_subtitle">"مطمئنید که می‌خواهید این اتاق را ترک کنید؟"</string>
</resources>

View file

@ -3,5 +3,8 @@
<string name="leave_conversation_alert_subtitle">"Êtes-vous sûr de vouloir quitter cette discussion ? Vous ne pourrez pas la rejoindre à nouveau sans y être invité."</string>
<string name="leave_room_alert_empty_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra rejoindre le salon à lavenir, y compris vous."</string>
<string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter ce salon ? Ce salon nest pas public et vous ne pourrez pas le rejoindre sans invitation."</string>
<string name="leave_room_alert_select_new_owner_action">"Choisissez les propriétaires"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Vous êtes le seul propriétaire de ce salon. Vous devez en transférer la propriété à quelquun dautre avant de le quitter."</string>
<string name="leave_room_alert_select_new_owner_title">"Transférer la propriété"</string>
<string name="leave_room_alert_subtitle">"Êtes-vous sûr de vouloir quitter le salon ?"</string>
</resources>

View file

@ -3,5 +3,8 @@
<string name="leave_conversation_alert_subtitle">"Czy na pewno chcesz opuścić tę konwersację? Konwersacja nie jest publiczna i nie będziesz mógł dołączyć ponownie bez zaproszenia."</string>
<string name="leave_room_alert_empty_subtitle">"Jesteś pewien, że chcesz opuścić ten pokój? Jesteś tu jedyną osobą. Jeśli wyjdziesz, nikt nie będzie mógł dołączyć, w tym Ty."</string>
<string name="leave_room_alert_private_subtitle">"Czy na pewno chcesz opuścić ten pokój? Ten pokój nie jest publiczny i nie będziesz mógł do niego wrócić bez zaproszenia."</string>
<string name="leave_room_alert_select_new_owner_action">"Wybierz właścicieli"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Jesteś jedynym właścicielem tego pokoju. Musisz przenieść własność na kogoś innego, zanim go opuścisz."</string>
<string name="leave_room_alert_select_new_owner_title">"Przenieś własność"</string>
<string name="leave_room_alert_subtitle">"Jesteś pewien, że chcesz wyjść z tego pokoju?"</string>
</resources>

View file

@ -3,5 +3,8 @@
<string name="leave_conversation_alert_subtitle">"Ste si istí, že chcete opustiť konverzáciu?"</string>
<string name="leave_room_alert_empty_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Ste tu jediná osoba. Ak odídete, nikto sa do nej nebude môcť v budúcnosti pripojiť, vrátane vás."</string>
<string name="leave_room_alert_private_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Táto miestnosť nie je verejná a bez pozvania sa do nej nebudete môcť vrátiť."</string>
<string name="leave_room_alert_select_new_owner_action">"Vybrať vlastníkov"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Ste jediným vlastníkom tejto miestnosti. Pred opustením miestnosti musíte previesť vlastníctvo na niekoho iného."</string>
<string name="leave_room_alert_select_new_owner_title">"Preniesť vlastníctvo"</string>
<string name="leave_room_alert_subtitle">"Ste si istí, že chcete opustiť miestnosť?"</string>
</resources>

View file

@ -3,5 +3,8 @@
<string name="leave_conversation_alert_subtitle">"Are you sure that you want to leave this conversation? This conversation is not public and you won\'t be able to rejoin without an invite."</string>
<string name="leave_room_alert_empty_subtitle">"Are you sure that you want to leave this room? You\'re the only person here. If you leave, no one will be able to join in the future, including you."</string>
<string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you won\'t be able to rejoin without an invite."</string>
<string name="leave_room_alert_select_new_owner_action">"Choose owners"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"You\'re the only owner of this room. You need to transfer ownership to someone else before you leave the room."</string>
<string name="leave_room_alert_select_new_owner_title">"Transfer ownership"</string>
<string name="leave_room_alert_subtitle">"Are you sure that you want to leave the room?"</string>
</resources>

View file

@ -18,10 +18,12 @@ android {
setupAnvil()
dependencies {
api(projects.features.leaveroom.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.features.leaveroom.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View file

@ -0,0 +1,14 @@
/*
* Copyright 2023, 2024 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.features.leaveroom.impl
import io.element.android.features.leaveroom.api.LeaveRoomEvent
sealed interface InternalLeaveRoomEvent : LeaveRoomEvent {
data object ResetState : InternalLeaveRoomEvent
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 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.features.leaveroom.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class InternalLeaveRoomRenderer @Inject constructor() : LeaveRoomRenderer {
@Composable
override fun Render(state: LeaveRoomState, onSelectNewOwners: (RoomId) -> Unit, modifier: Modifier) {
if (state is InternalLeaveRoomState) {
LeaveRoomView(state, onSelectNewOwners)
} else {
error("Unsupported state type ${state.javaClass}")
}
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 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.features.leaveroom.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
data class InternalLeaveRoomState(
val leaveAction: AsyncAction<Unit>,
override val eventSink: (LeaveRoomEvent) -> Unit
) : LeaveRoomState
@Immutable
sealed interface Confirmation : AsyncAction.Confirming {
data class Dm(val roomId: RoomId) : Confirmation
data class Generic(val roomId: RoomId) : Confirmation
data class PrivateRoom(val roomId: RoomId) : Confirmation
data class LastUserInRoom(val roomId: RoomId) : Confirmation
data class LastOwnerInRoom(val roomId: RoomId) : Confirmation
}

View file

@ -0,0 +1,51 @@
/*
* Copyright 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.features.leaveroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
class InternalLeaveRoomStateProvider : PreviewParameterProvider<InternalLeaveRoomState> {
override val values: Sequence<InternalLeaveRoomState>
get() = sequenceOf(
aLeaveRoomState(),
aLeaveRoomState(
leaveAction = Confirmation.Generic(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.PrivateRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.LastUserInRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.Dm(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = Confirmation.LastOwnerInRoom(roomId = A_ROOM_ID),
),
aLeaveRoomState(
leaveAction = AsyncAction.Loading,
),
aLeaveRoomState(
leaveAction = AsyncAction.Failure(RuntimeException("Something went wrong")),
),
)
}
private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
fun aLeaveRoomState(
leaveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (LeaveRoomEvent) -> Unit = {},
) = InternalLeaveRoomState(
leaveAction = leaveAction,
eventSink = eventSink,
)

View file

@ -14,15 +14,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Dm
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Generic
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom
import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
@ -35,71 +37,65 @@ class LeaveRoomPresenter @Inject constructor(
@Composable
override fun present(): LeaveRoomState {
val scope = rememberCoroutineScope()
val confirmation = remember { mutableStateOf<LeaveRoomState.Confirmation>(LeaveRoomState.Confirmation.Hidden) }
val progress = remember { mutableStateOf<LeaveRoomState.Progress>(LeaveRoomState.Progress.Hidden) }
val error = remember { mutableStateOf<LeaveRoomState.Error>(LeaveRoomState.Error.Hidden) }
return LeaveRoomState(
confirmation = confirmation.value,
progress = progress.value,
error = error.value,
val leaveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
return InternalLeaveRoomState(
leaveAction = leaveAction.value,
) { event ->
when (event) {
is LeaveRoomEvent.ShowConfirmation -> scope.launch(dispatchers.io) {
showLeaveRoomAlert(
matrixClient = client,
roomId = event.roomId,
confirmation = confirmation,
)
}
is LeaveRoomEvent.HideConfirmation -> confirmation.value = LeaveRoomState.Confirmation.Hidden
is LeaveRoomEvent.LeaveRoom -> scope.launch(dispatchers.io) {
client.leaveRoom(
roomId = event.roomId,
confirmation = confirmation,
progress = progress,
error = error,
)
}
is LeaveRoomEvent.HideError -> error.value = LeaveRoomState.Error.Hidden
is LeaveRoomEvent.LeaveRoom ->
if (event.needsConfirmation) {
scope.showLeaveRoomAlert(roomId = event.roomId, leaveAction = leaveAction)
} else {
scope.leaveRoom(roomId = event.roomId, leaveAction = leaveAction)
}
InternalLeaveRoomEvent.ResetState -> leaveAction.value = AsyncAction.Uninitialized
}
}
}
}
private suspend fun showLeaveRoomAlert(
matrixClient: MatrixClient,
roomId: RoomId,
confirmation: MutableState<LeaveRoomState.Confirmation>,
) {
matrixClient.getRoom(roomId)?.use { room ->
val roomInfo = room.roomInfoFlow.first()
confirmation.value = when {
roomInfo.isDm -> Dm(roomId)
// If unknown, assume the room is private
roomInfo.isPublic == null || roomInfo.isPublic == false -> PrivateRoom(roomId)
roomInfo.joinedMembersCount == 1L -> LastUserInRoom(roomId)
else -> Generic(roomId)
private fun CoroutineScope.showLeaveRoomAlert(
roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
client.getRoom(roomId)?.use { room ->
val roomInfo = room.roomInfoFlow.first()
leaveAction.value = when {
roomInfo.isDm -> Confirmation.Dm(roomId)
room.isLastOwner() && roomInfo.joinedMembersCount > 1L -> Confirmation.LastOwnerInRoom(roomId)
// If unknown, assume the room is private
roomInfo.isPublic == null || roomInfo.isPublic == false -> Confirmation.PrivateRoom(roomId)
roomInfo.joinedMembersCount == 1L -> Confirmation.LastUserInRoom(roomId)
else -> Confirmation.Generic(roomId)
}
}
}
private fun CoroutineScope.leaveRoom(
roomId: RoomId,
leaveAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
leaveAction.runCatchingUpdatingState {
client.getRoom(roomId)!!.use { room ->
room
.leave()
.onFailure { Timber.e(it, "Error while leaving room ${room.roomId}") }
.getOrThrow()
}
}
}
private suspend fun BaseRoom.isLastOwner(): Boolean {
if (roomInfoFlow.value.isDm) {
// DMs are not owned by the user, so we can return false
return false
} else {
val hasPrivilegedCreatorRole = roomInfoFlow.value.privilegedCreatorRole
if (!hasPrivilegedCreatorRole) return false
val creators = usersWithRole(RoomMember.Role.Owner(isCreator = true)).first()
val superAdmins = usersWithRole(RoomMember.Role.Owner(isCreator = false)).first()
val owners = creators + superAdmins
return owners.size == 1 && owners.first().userId == sessionId
}
}
}
private suspend fun MatrixClient.leaveRoom(
roomId: RoomId,
confirmation: MutableState<LeaveRoomState.Confirmation>,
progress: MutableState<LeaveRoomState.Progress>,
error: MutableState<LeaveRoomState.Error>,
) {
confirmation.value = LeaveRoomState.Confirmation.Hidden
progress.value = LeaveRoomState.Progress.Shown
getRoom(roomId)?.use { room ->
room.leave()
.onFailure {
Timber.e(it, "Error while leaving room ${room.roomId}")
error.value = LeaveRoomState.Error.Shown
}
}
progress.value = LeaveRoomState.Progress.Hidden
}

View file

@ -0,0 +1,149 @@
/*
* Copyright 2023, 2024 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.features.leaveroom.impl
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.R
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Suppress("LambdaParameterEventTrailing")
@Composable
fun LeaveRoomView(
state: InternalLeaveRoomState,
onSelectNewOwners: (RoomId) -> Unit,
) {
AsyncActionView(
state.leaveAction,
onSuccess = {
state.eventSink(InternalLeaveRoomEvent.ResetState)
},
onErrorDismiss = {
state.eventSink(InternalLeaveRoomEvent.ResetState)
},
confirmationDialog = { confirmation ->
if (confirmation is Confirmation) {
LeaveRoomConfirmationDialog(
confirmation = confirmation,
eventSink = state.eventSink,
onSelectNewOwners = onSelectNewOwners,
)
}
},
errorTitle = { stringResource(CommonStrings.common_something_went_wrong) },
errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) },
progressDialog = { LeaveRoomProgressDialog() },
)
}
@Composable
private fun LeaveRoomConfirmationDialog(
confirmation: Confirmation,
eventSink: (LeaveRoomEvent) -> Unit,
onSelectNewOwners: (RoomId) -> Unit,
) {
val defaultOnSubmitClick = { roomId: RoomId -> { eventSink(LeaveRoomEvent.LeaveRoom(roomId, needsConfirmation = false)) } }
val defaultDismissAction = { eventSink(InternalLeaveRoomEvent.ResetState) }
when (confirmation) {
is Confirmation.Dm -> LeaveRoomConfirmationDialog(
text = stringResource(R.string.leave_room_alert_private_subtitle),
isDm = false,
onSubmitClick = defaultOnSubmitClick(confirmation.roomId),
onDismiss = defaultDismissAction,
)
is Confirmation.PrivateRoom -> LeaveRoomConfirmationDialog(
text = stringResource(R.string.leave_room_alert_private_subtitle),
isDm = false,
onSubmitClick = defaultOnSubmitClick(confirmation.roomId),
onDismiss = defaultDismissAction,
)
is Confirmation.LastUserInRoom -> LeaveRoomConfirmationDialog(
text = stringResource(R.string.leave_room_alert_empty_subtitle),
isDm = false,
onSubmitClick = defaultOnSubmitClick(confirmation.roomId),
onDismiss = defaultDismissAction,
)
is Confirmation.LastOwnerInRoom -> LeaveRoomConfirmationDialog(
title = stringResource(R.string.leave_room_alert_select_new_owner_title),
text = stringResource(R.string.leave_room_alert_select_new_owner_subtitle),
isDm = false,
submitText = stringResource(R.string.leave_room_alert_select_new_owner_action),
destructiveSubmit = true,
onSubmitClick = {
onSelectNewOwners(confirmation.roomId)
eventSink(InternalLeaveRoomEvent.ResetState)
},
onDismiss = defaultDismissAction,
)
is Confirmation.Generic -> LeaveRoomConfirmationDialog(
text = stringResource(R.string.leave_room_alert_subtitle),
isDm = false,
onSubmitClick = defaultOnSubmitClick(confirmation.roomId),
onDismiss = defaultDismissAction,
)
}
}
@Composable
private fun LeaveRoomConfirmationDialog(
isDm: Boolean,
text: String,
onSubmitClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String = stringResource(if (isDm) CommonStrings.action_leave_conversation else CommonStrings.action_leave_room),
submitText: String = stringResource(CommonStrings.action_leave),
destructiveSubmit: Boolean = false,
) {
ConfirmationDialog(
title = title,
content = text,
submitText = submitText,
onSubmitClick = onSubmitClick,
onDismiss = onDismiss,
destructiveSubmit = destructiveSubmit,
modifier = modifier,
)
}
@Composable
private fun LeaveRoomProgressDialog(modifier: Modifier = Modifier) {
ProgressDialog(
text = stringResource(CommonStrings.common_leaving_room),
modifier = modifier,
)
}
@PreviewsDayNight
@Composable
internal fun LeaveRoomViewPreview(
@PreviewParameter(InternalLeaveRoomStateProvider::class) state: InternalLeaveRoomState
) = ElementPreview {
Box(
modifier = Modifier.size(300.dp, 300.dp),
propagateMinConstraints = true,
) {
LeaveRoomView(state = state, onSelectNewOwners = {})
}
}

View file

@ -12,7 +12,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -23,6 +23,8 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@ -36,15 +38,12 @@ class LeaveBaseRoomPresenterTest {
@Test
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createLeaveRoomPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Hidden)
assertThat(initialState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
assertThat(initialState.error).isEqualTo(LeaveRoomState.Error.Hidden)
}
createLeaveRoomPresenter()
.stateFlow()
.test {
val initialState = awaitItem()
assertThat(initialState.leaveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
@ -59,13 +58,11 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Generic(A_ROOM_ID))
assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.Generic(A_ROOM_ID))
}
}
@ -81,13 +78,11 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID))
assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.PrivateRoom(A_ROOM_ID))
}
}
@ -103,13 +98,11 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID))
assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.LastUserInRoom(A_ROOM_ID))
}
}
@ -125,13 +118,11 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = true))
val confirmationState = awaitItem()
assertThat(confirmationState.confirmation).isEqualTo(LeaveRoomState.Confirmation.Dm(A_ROOM_ID))
assertThat(confirmationState.leaveAction).isEqualTo(Confirmation.Dm(A_ROOM_ID))
}
}
@ -148,11 +139,9 @@ class LeaveBaseRoomPresenterTest {
)
},
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
advanceUntilIdle()
cancelAndIgnoreRemainingEvents()
assert(leaveRoomLambda)
@ -173,44 +162,19 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
skipItems(1) // Skip show progress state
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
val progressState = awaitItem()
assertThat(progressState.leaveAction).isEqualTo(AsyncAction.Loading)
val errorState = awaitItem()
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show progress indicator while leaving a room`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom(
leaveRoomLambda = { Result.success(Unit) }
),
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
val progressState = awaitItem()
assertThat(progressState.progress).isEqualTo(LeaveRoomState.Progress.Shown)
val finalState = awaitItem()
assertThat(finalState.progress).isEqualTo(LeaveRoomState.Progress.Hidden)
}
}
@Test
fun `present - hide error hides the error`() = runTest {
fun `present - reset state after error`() = runTest {
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
@ -221,20 +185,23 @@ class LeaveBaseRoomPresenterTest {
)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.stateFlow().test {
val initialState = awaitItem()
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID))
initialState.eventSink(LeaveRoomEvent.LeaveRoom(A_ROOM_ID, needsConfirmation = false))
skipItems(1) // Skip show progress state
val errorState = awaitItem()
assertThat(errorState.error).isEqualTo(LeaveRoomState.Error.Shown)
skipItems(1) // Skip hide progress state
errorState.eventSink(LeaveRoomEvent.HideError)
assertThat(errorState.leaveAction).isInstanceOf(AsyncAction.Failure::class.java)
errorState.eventSink(InternalLeaveRoomEvent.ResetState)
val hiddenErrorState = awaitItem()
assertThat(hiddenErrorState.error).isEqualTo(LeaveRoomState.Error.Hidden)
assertThat(hiddenErrorState.leaveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun LeaveRoomPresenter.stateFlow(): Flow<InternalLeaveRoomState> {
return moleculeFlow(RecompositionMode.Immediate) {
present()
}.filterIsInstance(InternalLeaveRoomState::class)
}
}
private fun TestScope.createLeaveRoomPresenter(

View file

@ -12,10 +12,10 @@
<string name="screen_app_lock_settings_remove_pin_alert_title">"برداشتن پین؟"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"اجازه به %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"ترجیح می‌دهم از پین استفاده کنم"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"زمیانتان را ذخیره کرده و از %1$s برای قفل‌گشایی هربارهٔ کاره استفاده کنید"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"زمانتان را ذخیره کرده و از %1$s برای قفل‌گشایی هربارهٔ کاره استفاده کنید"</string>
<string name="screen_app_lock_setup_choose_pin">"گزینش پین"</string>
<string name="screen_app_lock_setup_confirm_pin">"تأیید پین"</string>
<string name="screen_app_lock_setup_pin_context">"قفل %1$s برای افزودن امنیت بیشتر به گفتگوهایتان.
<string name="screen_app_lock_setup_pin_context">"قفل کردن %1$s برای افزودن امنیت بیشتر به گفتگوهایتان.
چیزی به یاد ماندنی انتخاب کنید. اگر این پین را فراموش کنید، از برنامه خارج خواهید شد."</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_content">"به دلیل امنیتی نمی‌توانید این پین را برگزینید"</string>
@ -24,14 +24,6 @@
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"پین‌ها مطابق نیستند"</string>
<string name="screen_app_lock_signout_alert_message">"برای ادامه باید دوباره وارد شده و پینی جدید ایجاد کنید"</string>
<string name="screen_app_lock_signout_alert_title">"دارید خارج می‌شوید"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"شما %1$d تلاش برای باز کردن قفل دارید"</item>
<item quantity="other">"شما %1$d تلاش برای باز کردن قفل دارید"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"پین اشتباه است. شما %1$d شانس دیگر دارید"</item>
<item quantity="other">"پین اشتباه است. شما %1$d شانس دیگر دارید"</item>
</plurals>
<string name="screen_app_lock_use_biometric_android">"استفاده از زیست‌سنجی"</string>
<string name="screen_app_lock_use_pin_android">"استفاده از پین"</string>
<string name="screen_signout_in_progress_dialog_content">"خارج شدن…"</string>

View file

@ -13,6 +13,8 @@
<string name="screen_change_account_provider_other">"Anden"</string>
<string name="screen_change_account_provider_subtitle">"Brug en anden kontoudbyder, f.eks. din egen private server eller en arbejdskonto."</string>
<string name="screen_change_account_provider_title">"Skift kontoudbyder"</string>
<string name="screen_change_server_error_element_pro_required_message">"Element Pro-appen er påkrævet på %1$s Download den venligst fra din app store."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro kræves"</string>
<string name="screen_change_server_error_invalid_homeserver">"Vi kunne ikke nå denne hjemmeserver. Kontroller, at du har indtastet hjemmeserverens URL korrekt. Hvis URL-adressen er korrekt, skal du kontakte administratoren på din hjemmeserver for at få yderligere hjælp."</string>
<string name="screen_change_server_error_invalid_well_known">"Serveren er ikke tilgængelig på grund af et problem i .well-known-filen:
%1$s"</string>

View file

@ -13,7 +13,7 @@
<string name="screen_change_account_provider_other">"دیگر"</string>
<string name="screen_change_account_provider_subtitle">"استفاده از فراهم کنندهٔ حسابی دیگر چون کارساز خصوصی خوتان یا حسابی کاری."</string>
<string name="screen_change_account_provider_title">"تغییر فراهم کنندهٔ حساب"</string>
<string name="screen_change_server_error_invalid_homeserver">"ما نتوانستیم به این سرور خانگی برسیم. لطفاً بررسی کنید که URL سرور اصلی را به درستی وارد کرده اید. اگر URL صحیح است، برای کمک بیشتر با مدیر سرور خانگی خود تماس بگیرید."</string>
<string name="screen_change_server_error_invalid_homeserver">"ما نتوانستیم به این کارساز خانگی برسیم. لطفاً بررسی کنید که URL کارساز اصلی را به درستی وارد کرده اید. اگر URL صحیح است، برای کمک بیشتر با مدیر کارساز خانگی خود تماس بگیرید."</string>
<string name="screen_change_server_form_header">"نشانی کارساز خانگی"</string>
<string name="screen_change_server_form_notice">"ورود نشانی دامنه."</string>
<string name="screen_change_server_subtitle">"نشانی کارسازتان چیست؟"</string>
@ -22,12 +22,14 @@
<string name="screen_login_error_deactivated_account">"این حساب از کار افتاده است."</string>
<string name="screen_login_error_invalid_credentials">"نام کاربری یا گذرواژه نامعتبر است"</string>
<string name="screen_login_error_invalid_user_id">"این یک شناسه کاربری معتبر نیست. قالب صحیح: ‪«@user:homeserver.or"</string>
<string name="screen_login_error_unsupported_authentication">"سرور اصلی انتخاب شده از رمز عبور یا ورود OIDC پشتیبانی نمی کند. لطفا با مدیر خود تماس بگیرید یا یک سرور خانگی دیگر را انتخاب کنید."</string>
<string name="screen_login_error_unsupported_authentication">"کارساز اصلی انتخاب شده از رمز عبور یا ورود OIDC پشتیبانی نمی کند. لطفا با مدیر خود تماس بگیرید یا یک کارساز خانگی دیگر را انتخاب کنید."</string>
<string name="screen_login_form_header">"جزییاتتان را وارد کنید"</string>
<string name="screen_login_subtitle">"ماتریکس شبکه‌ای بار برای ارتباطات نامتمرکز و امن است."</string>
<string name="screen_login_title">"خوش برگشتید!"</string>
<string name="screen_login_title_with_homeserver">"ورود به %1$s"</string>
<string name="screen_onboarding_app_version">"نگارش %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"ورود دستی"</string>
<string name="screen_onboarding_sign_in_to">"ورود به %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"ورود با کد QR"</string>
<string name="screen_onboarding_sign_up">"ایجاد حساب"</string>
<string name="screen_onboarding_welcome_message">"به سریع‌ترین %1$s خوش آمدید. بازطرّاحی شده برای سرعت و سادگی."</string>
@ -74,7 +76,7 @@
<string name="screen_qr_code_login_verify_code_subtitle">"ممکن است فراهم کنندهٔ حسابتان کد زیر را برای تأیید ورود بخواهد."</string>
<string name="screen_qr_code_login_verify_code_title">"کد تأییدتان"</string>
<string name="screen_server_confirmation_change_server">"تغییر فراهم کنندهٔ حساب"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"یک سرور خصوصی برای کارمندان Element."</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"کارساز خصوصی برای کارمندان المنت."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"ماتریکس شبکه‌ای بار برای ارتباطات نامتمرکز و امن است."</string>
<string name="screen_server_confirmation_message_register">"جایی که گفت‌وگوهایتان خواهند زیست — درست مثل استفاده‌تان از فراهم کنندهٔ رایانامه‌ای برای نگه داشتن رایانامه‌هایتان."</string>
<string name="screen_server_confirmation_title_login">"دارید به %1$s وارد می‌شوید"</string>

View file

@ -13,6 +13,8 @@
<string name="screen_change_account_provider_other">"Muu"</string>
<string name="screen_change_account_provider_subtitle">"Käytä toista palveluntarjoajaa, kuten omaa yksityistä palvelintasi tai työpaikkaasi."</string>
<string name="screen_change_account_provider_title">"Vaihda palveluntarjoajaa"</string>
<string name="screen_change_server_error_element_pro_required_message">"Element Pro -sovellus on pakollinen %1$s -palvelimella. Lataa se sovelluskaupasta."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro vaaditaan"</string>
<string name="screen_change_server_error_invalid_homeserver">"Kotipalvelimeen ei saatu yhteyttä. Varmista, että olet syöttänyt osoitteen oikein. Jos osoite on oikein, ota yhteyttä palvelimesi ylläpitäjään."</string>
<string name="screen_change_server_error_invalid_well_known">"Palvelin ei ole saatavilla .well-known tiedostossa olevan ongelman vuoksi:
%1$s"</string>
@ -34,6 +36,7 @@
<string name="screen_login_subtitle">"Matrix on avoin verkko turvallista, hajautettua viestintää varten."</string>
<string name="screen_login_title">"Tervetuloa takaisin!"</string>
<string name="screen_login_title_with_homeserver">"Kirjaudu sisään %1$s -palvelimelle"</string>
<string name="screen_onboarding_app_version">"Versio %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Kirjaudu sisään manuaalisesti"</string>
<string name="screen_onboarding_sign_in_to">"Kirjaudu sisään %1$s -palvelimelle"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Kirjaudu sisään QR-koodilla"</string>

View file

@ -14,7 +14,7 @@
<string name="screen_change_account_provider_subtitle">"Gebruik een andere accountprovider, zoals je eigen privéserver of een zakelijke account."</string>
<string name="screen_change_account_provider_title">"Wijzig accountprovider"</string>
<string name="screen_change_server_error_invalid_homeserver">"We konden deze homeserver niet bereiken. Controleer of je de homeserver-URL juist hebt ingevoerd. Als de URL juist is, neem dan contact op met de beheerder van je homeserver voor verdere hulp."</string>
<string name="screen_change_server_error_invalid_well_known">"Sliding sync is niet beschikbaar vanwege een probleem in het well-known bestand:
<string name="screen_change_server_error_invalid_well_known">"Server is niet beschikbaar vanwege een probleem in het well-known bestand:
%1$s"</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string>
<string name="screen_change_server_subtitle">"Wat is het adres van je server?"</string>

View file

@ -13,6 +13,8 @@
<string name="screen_change_account_provider_other">"Інше"</string>
<string name="screen_change_account_provider_subtitle">"Використати іншого провайдера облікових записів, наприклад, власний приватний сервер або робочий обліковий запис."</string>
<string name="screen_change_account_provider_title">"Змінити провайдера облікового запису"</string>
<string name="screen_change_server_error_element_pro_required_message">"Необхідно встановити застосунок Element Pro на %1$s. Завантажте його з магазину."</string>
<string name="screen_change_server_error_element_pro_required_title">"Потрібен Element Pro"</string>
<string name="screen_change_server_error_invalid_homeserver">"Не вдалося під\'єднатися до цього домашнього сервера. Перевірте правильність введеної URL-адреси домашнього сервера. Якщо URL-адреса правильна, зверніться по додаткову допомогу до адміністратора домашнього сервера."</string>
<string name="screen_change_server_error_invalid_well_known">"Сервер недоступний через помилку у файлі well-known:
%1$s"</string>
@ -34,6 +36,7 @@
<string name="screen_login_subtitle">"Matrix — це відкрита мережа для безпечної, децентралізованої комунікації."</string>
<string name="screen_login_title">"З поверненням!"</string>
<string name="screen_login_title_with_homeserver">"Увійти в %1$s"</string>
<string name="screen_onboarding_app_version">"Версія %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Увійти вручну"</string>
<string name="screen_onboarding_sign_in_to">"Увійти в %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Увійти за допомогою QR-коду"</string>

View file

@ -10,7 +10,7 @@
<string name="emoji_picker_category_symbols">"نمادها"</string>
<string name="screen_report_content_block_user">"انسداد کاربر"</string>
<string name="screen_report_content_block_user_hint">"اگر می‌خواهید همه پیام‌های فعلی و آینده را از این کاربر را پنهان کنید، علامت بزنید"</string>
<string name="screen_report_content_explanation">"این پیام به مدیر سرور خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود."</string>
<string name="screen_report_content_explanation">"این پیام به مدیر کارساز خانگی شما گزارش خواهد شد. آنها قادر به خواندن پیام های رمزگذاری شده نخواهند بود."</string>
<string name="screen_report_content_hint">"دلیل گزارش این محتوا"</string>
<string name="screen_room_attachment_source_camera">"دوربین"</string>
<string name="screen_room_attachment_source_camera_photo">"عکس گرفتن"</string>
@ -36,9 +36,5 @@
<string name="screen_room_timeline_reactions_show_less">"نمایش کم‌تر"</string>
<string name="screen_room_timeline_reactions_show_more">"نمایش بیش‌تر"</string>
<string name="screen_room_timeline_read_marker_title">"جدید"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$dتغییر اتاق"</item>
<item quantity="other">"%1$dتغییر اتاق"</item>
</plurals>
<string name="screen_room_typing_two_members">"%1$s و %2$s"</string>
</resources>

View file

@ -28,15 +28,25 @@
<string name="screen_room_mentions_at_room_title">"Kaikki"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Lähetä uudelleen"</string>
<string name="screen_room_retry_send_menu_title">"Viestisi lähettäminen epäonnistui"</string>
<string name="screen_room_timeline_add_reaction">"Lisää emoji"</string>
<string name="screen_room_timeline_add_reaction">"Lisää reaktio"</string>
<string name="screen_room_timeline_beginning_of_room">"Tämä on huoneen %1$s alku."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"Tämä on tämän keskustelun alku."</string>
<string name="screen_room_timeline_legacy_call">"Puhelu, jota ei tueta. Kysy, voiko soittaja käyttää uutta Element X -sovellusta."</string>
<string name="screen_room_timeline_less_reactions">"Näytä vähemmän"</string>
<string name="screen_room_timeline_message_copied">"Viesti kopioitu"</string>
<string name="screen_room_timeline_no_permission_to_post">"Sinulla ei ole oikeutta kirjoittaa tässä huoneessa"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d jäsen reagoi %2$s"</item>
<item quantity="other">"%1$d jäsentä reagoivat %2$s"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"Sinä ja %1$d jäsen reagoitte %2$s"</item>
<item quantity="other">"Sinä ja %1$d jäsentä reagoitte %2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"Reagoit seuraavasti: %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Näytä vähemmän"</string>
<string name="screen_room_timeline_reactions_show_more">"Näytä lisää"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Näytä reaktioiden yhteenveto"</string>
<string name="screen_room_timeline_read_marker_title">"Uusi"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d muutos huoneeseen"</item>

View file

@ -4,5 +4,6 @@
<item quantity="one">"%1$d% af de samlede stemmer"</item>
<item quantity="other">"%1$d procent af det samlede antal stemmer"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Fjerner tidligere valg"</string>
<string name="a11y_polls_winning_answer">"Dette er det vindende svar"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_polls_will_remove_selection">"گزینش پیشین را برخواهد داشت"</string>
<string name="a11y_polls_winning_answer">"این پاسخ برنده است"</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"%1$d prosentti kaikista äänistä"</item>
<item quantity="other">"%1$d prosenttia kaikista äänistä"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Poistaa edellisen valinnan"</string>
<string name="a11y_polls_winning_answer">"Tämä on voittava vastaus"</string>
</resources>

View file

@ -5,5 +5,6 @@
<item quantity="few">"%1$d відсотки від усіх голосів"</item>
<item quantity="many">"%1$d відсотків від усіх голосів"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Попередній вибір буде прибрано"</string>
<string name="a11y_polls_winning_answer">"Ця відповідь перемогла"</string>
</resources>

View file

@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Piilota äänet"</string>
<string name="screen_create_poll_answer_hint">"Vaihtoehto %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Muutoksiasi ei ole tallennettu. Haluatko varmasti palata takaisin?"</string>
<string name="screen_create_poll_delete_option_a11y">"Poista vaihtoehto %1$s"</string>
<string name="screen_create_poll_question_desc">"Kysymys tai aihe"</string>
<string name="screen_create_poll_question_hint">"Mistä kyselyssä on kyse?"</string>
<string name="screen_create_poll_title">"Luo kysely"</string>

View file

@ -7,6 +7,7 @@
<string name="screen_advanced_settings_element_call_base_url">"نشانی پایهٔ تماس المنتی سفارشی"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"تنظمی نشانی پایه‌‌ای سفارشی برای تماس المنتی."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL نامعتبر، لطفا مطمئن شوید که پروتکل (http/https) و آدرس صحیح را درج کرده اید."</string>
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"نهفتن چهرک‌ها در درخواست‌های دعوت اتاق"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"نهفتن رسانه در خط زمانی"</string>
<string name="screen_advanced_settings_media_compression_title">"بهینه سازی کیفیت رسانه"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"نظارت و امنیت"</string>
@ -17,6 +18,7 @@
<string name="screen_advanced_settings_show_media_timeline_always_hide">"نهفتن همیشگی"</string>
<string name="screen_advanced_settings_show_media_timeline_always_show">"نمایش همیشگی"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"در اتاق‌های خصوصی"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"رسانه‌های نهفته همواره خواهند توانست با زدن رویشان نمایان شوند"</string>
<string name="screen_advanced_settings_show_media_timeline_title">"نمایش رسانه در خط زمانی"</string>
<string name="screen_blocked_users_empty">"هیچ کاربر مسدودی ندارید"</string>
<string name="screen_blocked_users_unblock_alert_action">"رفع انسداد"</string>
@ -56,6 +58,7 @@
<string name="screen_notification_settings_system_notifications_action_required_content_link">"تنظیمات سامانه"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"آگاهی‌های سامانه‌ای خاموش شدند"</string>
<string name="screen_notification_settings_title">"آگاهی‌ها"</string>
<string name="troubleshoot_notifications_entry_point_push_history_title">"تاریخچهٔ فرستادن"</string>
<string name="troubleshoot_notifications_entry_point_section">"رفع‌اشکال"</string>
<string name="troubleshoot_notifications_entry_point_title">"رفع‌اشکال آگاهی‌ها"</string>
</resources>

View file

@ -4,13 +4,13 @@
<string name="screen_bug_report_contact_me">"اگر پرسش دیگری دارید، می‌توانید با من در تماس باشید."</string>
<string name="screen_bug_report_contact_me_title">"تماس با من"</string>
<string name="screen_bug_report_edit_screenshot">"ویرایش نماگرفت"</string>
<string name="screen_bug_report_editor_description">"لطفا مشکل را توصیف کنید. چيکار کردي؟ انتظار داشتید چه اتفاقی بیفتد؟ واقعا چه اتفاقی افتاد لطفا تا جایی که می توانید جزئیات را وارد کنید."</string>
<string name="screen_bug_report_editor_description">"لطفاً مشکل را شرح دهید. چه‌کار کردید؟ انتظار داشتید چه بشود؟ ولی چه شد؟ لطفاً‌تا جای ممکن وارد جزییات شوید."</string>
<string name="screen_bug_report_editor_placeholder">"شرح مشکل…"</string>
<string name="screen_bug_report_editor_supporting">"ترجیحاً توضیحات را به زبان انگلیسی بنویسید."</string>
<string name="screen_bug_report_include_crash_logs">"ارسال رخدادنگارهای خطا"</string>
<string name="screen_bug_report_include_logs">"اجازه به گزارش‌ها"</string>
<string name="screen_bug_report_include_screenshot">"ارسال تصویر صفحه"</string>
<string name="screen_bug_report_logs_description">"گزارش ها در پیام شما گنجانده می شوند تا مطمئن شوید همه چیز به درستی کار می کند. برای ارسال پیام بدون گزارش، این تنظیم را خاموش کنید."</string>
<string name="screen_bug_report_logs_description">"برای اطمینان از درست کار کردن همه‌چیز گزارش‌ها در پیامتان قرار خواهد گرفت. برای فرستادن پیام بدون گزارش‌ها این تنظیم را خاموش کنید."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$sآخرین باری که استفاده شد، از کار افتاد. آیا مایلید گزارش خرابی را با ما به اشتراک بگذارید؟"</string>
<string name="screen_bug_report_view_logs">"دیدن گزارش‌ها"</string>
</resources>

View file

@ -10,6 +10,7 @@
<string name="screen_bug_report_error_description_too_short">"Kuvaus on liian lyhyt. Kerro tarkemmin mitä tapahtui, kiitos!"</string>
<string name="screen_bug_report_include_crash_logs">"Lähetä kaatumislokit"</string>
<string name="screen_bug_report_include_logs">"Lähetä lokitiedostot"</string>
<string name="screen_bug_report_include_logs_error">"Lokitiedostosi ovat liian suuria, joten niitä ei voida sisällyttää tähän raporttiin. Lähetä ne meille toisella tavalla."</string>
<string name="screen_bug_report_include_screenshot">"Lähetä kuvakaappaus"</string>
<string name="screen_bug_report_logs_description">"Lähetä lokitiedostot viestisi kanssa, jotta voimme varmistaa, että kaikki toimii oikein. Jos haluat lähettää viestisi ilman lokeja, jätä tämä asetus valitsematta."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s kaatui edellisellä käyttökerralla. Haluatko jakaa virheraportin kanssamme?"</string>

View file

@ -55,6 +55,7 @@ dependencies {
implementation(projects.features.verifysession.api)
implementation(projects.features.reportroom.api)
implementation(projects.features.roommembermoderation.api)
implementation(projects.features.changeroommemberroles.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View file

@ -8,7 +8,7 @@
package io.element.android.features.roomdetails.impl
sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent
data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent
data class CopyToClipboard(val text: String) : RoomDetailsEvent

View file

@ -11,6 +11,8 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -25,6 +27,8 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.appconfig.LearnMoreConfig
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
@ -51,12 +55,15 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@ -65,7 +72,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val pollHistoryEntryPoint: PollHistoryEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val room: BaseRoom,
private val room: JoinedRoom,
private val analyticsService: AnalyticsService,
private val messagesEntryPoint: MessagesEntryPoint,
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
@ -73,6 +80,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
private val mediaGalleryEntryPoint: MediaGalleryEntryPoint,
private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
@ -132,6 +140,24 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object ReportRoom : NavTarget
@Parcelize
data object SelectNewOwnersWhenLeaving : NavTarget
}
override fun onBuilt() {
super.onBuilt()
whenChildrenAttached { commonLifecycle: Lifecycle,
roomDetailsNode: RoomDetailsNode,
changeRoomMemberRolesNode: ChangeRoomMemberRolesEntryPoint.NodeProxy ->
commonLifecycle.coroutineScope.launch {
changeRoomMemberRolesNode.waitForRoleChanged()
withContext(NonCancellable) {
backstack.pop()
roomDetailsNode.onNewOwnersSelected()
}
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -198,6 +224,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openReportRoom() {
backstack.push(NavTarget.ReportRoom)
}
override fun onSelectNewOwnersWhenLeaving() {
backstack.push(NavTarget.SelectNewOwnersWhenLeaving)
}
}
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
}
@ -330,7 +360,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.VerifyUser -> {
val params = OutgoingVerificationEntryPoint.Params(
showDeviceVerifiedScreen = true,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId,)
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId)
)
outgoingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(params)
@ -352,6 +382,13 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.ReportRoom -> {
reportRoomEntryPoint.createNode(this, buildContext, room.roomId)
}
is NavTarget.SelectNewOwnersWhenLeaving -> {
changeRoomMemberRolesEntryPoint.builder(this, buildContext)
.room(room)
.listType(ChangeRoomMemberRolesListType.SelectNewOwnersWhenLeaving)
.build()
}
}
}

View file

@ -9,6 +9,8 @@ package io.element.android.features.roomdetails.impl
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
@ -21,7 +23,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.leaveroom.api.LeaveRoomRenderer
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom
@ -38,6 +42,7 @@ class RoomDetailsNode @AssistedInject constructor(
private val presenter: RoomDetailsPresenter,
private val room: BaseRoom,
private val analyticsService: AnalyticsService,
private val leaveRoomRenderer: LeaveRoomRenderer,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openRoomMemberList()
@ -54,9 +59,10 @@ class RoomDetailsNode @AssistedInject constructor(
fun openDmUserProfile(userId: UserId)
fun onJoinCall()
fun openReportRoom()
fun onSelectNewOwnersWhenLeaving()
}
private val callbacks = plugins<Callback>()
private val callback = plugins<Callback>().first()
init {
lifecycle.subscribe(
@ -67,27 +73,27 @@ class RoomDetailsNode @AssistedInject constructor(
}
private fun openRoomMemberList() {
callbacks.forEach { it.openRoomMemberList() }
callback.openRoomMemberList()
}
private fun openRoomNotificationSettings() {
callbacks.forEach { it.openRoomNotificationSettings() }
callback.openRoomNotificationSettings()
}
private fun invitePeople() {
callbacks.forEach { it.openInviteMembers() }
callback.openInviteMembers()
}
private fun openPollHistory() {
callbacks.forEach { it.openPollHistory() }
callback.openPollHistory()
}
private fun openMediaGallery() {
callbacks.forEach { it.openMediaGallery() }
callback.openMediaGallery()
}
private fun onJoinCall() {
callbacks.forEach { it.onJoinCall() }
callback.onJoinCall()
}
private fun CoroutineScope.onShareRoom(context: Context) = launch {
@ -106,41 +112,51 @@ class RoomDetailsNode @AssistedInject constructor(
}
private fun onEditRoomDetails() {
callbacks.forEach { it.editRoomDetails() }
callback.editRoomDetails()
}
private fun openAvatarPreview(name: String, url: String) {
callbacks.forEach { it.openAvatarPreview(name, url) }
callback.openAvatarPreview(name, url)
}
private fun openAdminSettings() {
callbacks.forEach { it.openAdminSettings() }
callback.openAdminSettings()
}
private fun openPinnedMessages() {
callbacks.forEach { it.openPinnedMessagesList() }
callback.openPinnedMessagesList()
}
private fun openKnockRequestsLists() {
callbacks.forEach { it.openKnockRequestsList() }
callback.openKnockRequestsList()
}
private fun openSecurityAndPrivacy() {
callbacks.forEach { it.openSecurityAndPrivacy() }
callback.openSecurityAndPrivacy()
}
private fun onProfileClick(userId: UserId) {
callbacks.forEach { it.openDmUserProfile(userId) }
callback.openDmUserProfile(userId)
}
private fun onReportRoomClick() {
callbacks.forEach { it.openReportRoom() }
callback.openReportRoom()
}
private fun onSelectNewOwnersWhenLeaving() {
return callback.onSelectNewOwnersWhenLeaving()
}
private val stateFlow = launchMolecule { presenter.present() }
fun onNewOwnersSelected() {
stateFlow.value.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
val state = presenter.present()
val state by stateFlow.collectAsState()
fun onShareRoom() {
lifecycleScope.onShareRoom(context)
@ -172,6 +188,13 @@ class RoomDetailsNode @AssistedInject constructor(
onSecurityAndPrivacyClick = ::openSecurityAndPrivacy,
onProfileClick = ::onProfileClick,
onReportRoomClick = ::onReportRoomClick,
leaveRoomView = {
leaveRoomRenderer.Render(
state = state.leaveRoomState,
onSelectNewOwners = { onSelectNewOwnersWhenLeaving() },
modifier = Modifier
)
}
)
}
}

View file

@ -149,8 +149,9 @@ class RoomDetailsPresenter @Inject constructor(
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
RoomDetailsEvent.LeaveRoom ->
leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(room.roomId))
is RoomDetailsEvent.LeaveRoom -> {
leaveRoomState.eventSink(LeaveRoomEvent.LeaveRoom(room.roomId, needsConfirmation = event.needsConfirmation))
}
RoomDetailsEvent.MuteNotification -> {
scope.launch(dispatchers.io) {
client.notificationSettingsService().muteRoom(room.roomId)

View file

@ -8,8 +8,8 @@
package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomdetails.impl.members.aRoomMember
@ -156,6 +156,12 @@ fun aRoomDetailsState(
eventSink = eventSink,
)
internal fun aLeaveRoomState(
eventSink: (LeaveRoomEvent) -> Unit = {}
) = object : LeaveRoomState {
override val eventSink: (LeaveRoomEvent) -> Unit = eventSink
}
fun aRoomNotificationSettings(
mode: RoomNotificationMode = RoomNotificationMode.MUTE,
isDefault: Boolean = false,

View file

@ -38,7 +38,6 @@ import androidx.compose.ui.unit.dp
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomcall.api.hasPermissionToJoin
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
@ -112,6 +111,7 @@ fun RoomDetailsView(
onProfileClick: (UserId) -> Unit,
onReportRoomClick: () -> Unit,
modifier: Modifier = Modifier,
leaveRoomView: @Composable () -> Unit,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
@ -131,7 +131,7 @@ fun RoomDetailsView(
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding)
) {
LeaveRoomView(state = state.leaveRoomState)
leaveRoomView()
when (state.roomType) {
RoomDetailsType.Room -> {
@ -262,7 +262,7 @@ fun RoomDetailsView(
OtherActionsSection(
canReportRoom = state.canReportRoom,
onReportRoomClick = onReportRoomClick,
onLeaveRoomClick = { state.eventSink(RoomDetailsEvent.LeaveRoom) }
onLeaveRoomClick = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) }
)
if (state.showDebugInfo) {
@ -776,5 +776,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
onSecurityAndPrivacyClick = {},
onProfileClick = {},
onReportRoomClick = {},
leaveRoomView = {},
)
}

View file

@ -38,8 +38,10 @@ import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ -64,6 +66,11 @@ class RoomMemberListPresenter @Inject constructor(
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
val roomModerationState = roomMembersModerationPresenter.present()
val activeRoomMemberCount by produceState(0L) {
room.roomInfoFlow.map { it.activeMembersCount }
.distinctUntilChanged()
.collect { value = it }
}
val roomMemberIdentityStates by produceState(persistentMapOf<UserId, IdentityState>()) {
room.roomMemberIdentityStateChange(waitForEncryption = true)
@ -73,8 +80,8 @@ class RoomMemberListPresenter @Inject constructor(
.launchIn(this)
}
// Ensure we load the latest data when entering this screen
LaunchedEffect(Unit) {
// Update the room members when the screen is loaded or the active member count changes
LaunchedEffect(activeRoomMemberCount) {
room.updateMembers()
}

View file

@ -10,27 +10,34 @@ package io.element.android.features.roomdetails.impl.rolesandpermissions
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesNode
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesEntryPoint
import io.element.android.features.changeroommemberroes.api.ChangeRoomMemberRolesListType
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.permissions.ChangeRoomPermissionsSection
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class RolesAndPermissionsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
private val joinedRoom: JoinedRoom,
) : BaseFlowNode<RolesAndPermissionsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.AdminSettings,
@ -53,6 +60,16 @@ class RolesAndPermissionsFlowNode @AssistedInject constructor(
data class ChangeRoomPermissions(val section: ChangeRoomPermissionsSection) : NavTarget
}
override fun onBuilt() {
super.onBuilt()
whenChildAttached { lifecycle, node: ChangeRoomMemberRolesEntryPoint.NodeProxy ->
lifecycle.coroutineScope.launch {
node.waitForRoleChanged()
backstack.pop()
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.AdminSettings -> {
@ -83,18 +100,16 @@ class RolesAndPermissionsFlowNode @AssistedInject constructor(
)
}
is NavTarget.AdminList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Admins)
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
changeRoomMemberRolesEntryPoint.builder(this, buildContext)
.room(joinedRoom)
.listType(ChangeRoomMemberRolesListType.Admins)
.build()
}
is NavTarget.ModeratorList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Moderators)
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
changeRoomMemberRolesEntryPoint.builder(this, buildContext)
.room(joinedRoom)
.listType(ChangeRoomMemberRolesListType.Moderators)
.build()
}
is NavTarget.ChangeRoomPermissions -> {
val inputs = ChangeRoomPermissionsNode.Inputs(navTarget.section)

View file

@ -22,13 +22,17 @@
<string name="screen_room_change_role_administrators_title">"Redigér admins"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Du kan ikke fortryde denne handling. Du forfremmer brugeren til at have samme magtniveau som dig."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Tilføj Admin?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Du kan ikke fortryde denne handling. Du overfører ejerskabet til de valgte brugere. Når du forlader siden, vil dette være permanent."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Overdrag ejerskab?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Nedgradering"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Du vil ikke være i stand til at fortryde denne ændring, da du degraderer dig selv. Hvis du er den sidste privilegerede bruger i rummet, vil det være umuligt at genvinde privilegier."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Nedgrader dig selv?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Afventer)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(Afventer)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Administratorer har automatisk moderatorrettigheder"</string>
<string name="screen_room_change_role_moderators_owner_section_footer">"Ejere har automatisk administratorrettigheder."</string>
<string name="screen_room_change_role_moderators_title">"Redigér moderatorer"</string>
<string name="screen_room_change_role_owners_title">"Vælg ejere"</string>
<string name="screen_room_change_role_section_administrators">"Administratorer"</string>
<string name="screen_room_change_role_section_moderators">"Moderatorer"</string>
<string name="screen_room_change_role_section_users">"Medlemmer"</string>
@ -81,6 +85,7 @@
<string name="screen_room_member_list_pending_header_title">"Afventer"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Moderator"</string>
<string name="screen_room_member_list_role_owner">"Ejeren"</string>
<string name="screen_room_member_list_room_members_header_title">"Medlemmer af rummet"</string>
<string name="screen_room_member_list_unbanning_user">"Ophæver spærring af %1$s"</string>
<string name="screen_room_notification_settings_allow_custom">"Tillad brugerdefineret indstilling"</string>
@ -98,12 +103,14 @@
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Kun omtaler og nøgleord"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Giv mig besked i dette rum for"</string>
<string name="screen_room_roles_and_permissions_admins">"Administratorer"</string>
<string name="screen_room_roles_and_permissions_admins_and_owners">"Administratorer og ejere"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Skift min rolle"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Nedgrader til medlem"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Nedgradering til moderator"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Moderation af medlemmer"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Beskeder og indhold"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderatorer"</string>
<string name="screen_room_roles_and_permissions_owners">"Ejere"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Tilladelser"</string>
<string name="screen_room_roles_and_permissions_reset">"Nulstil tilladelser"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Når du nulstiller tilladelserne, mister du de nuværende indstillinger."</string>

View file

@ -22,13 +22,17 @@
<string name="screen_room_change_role_administrators_title">"Muuda peakasutajaid"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Kuna sa annad teisele kasutajale sinu õigustega võrreldes samad õigused, siis sa ei saa seda muudatust hiljem tagasi pöörata."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Lisame peakasutaja?"</string>
<string name="screen_room_change_role_confirm_change_owners_description">"Seda tegevust ei saa tagasi pöörata. Järgnevaga annad jututoa omandi üle valitud kasutajatele. Kui lahkus, siis muutub see muudatus püsivaks."</string>
<string name="screen_room_change_role_confirm_change_owners_title">"Kas soovid omandi üle anda?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Vähenda õigusi"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Kui sa võtad endalt kõik õigused ära ja oled viimane peakasutaja selles jututoas, siis sa ei saa seda muudatust hiljem tagasi pöörata."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Kas vähendad enda õigusi?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (ootel)"</string>
<string name="screen_room_change_role_invited_member_name_android">"(ootel)"</string>
<string name="screen_room_change_role_moderators_admin_section_footer">"Peakasutajatel on automaatselt ka moderaatori õigused"</string>
<string name="screen_room_change_role_moderators_owner_section_footer">"Omanikel on automaatselt ka peakasutaja õigused."</string>
<string name="screen_room_change_role_moderators_title">"Muuda moderaatoreid"</string>
<string name="screen_room_change_role_owners_title">"Vali omanikud"</string>
<string name="screen_room_change_role_section_administrators">"Peakasutajad"</string>
<string name="screen_room_change_role_section_moderators">"Moderaatorid"</string>
<string name="screen_room_change_role_section_users">"Liikmed"</string>
@ -81,6 +85,7 @@
<string name="screen_room_member_list_pending_header_title">"Ootel"</string>
<string name="screen_room_member_list_role_administrator">"Peakasutaja"</string>
<string name="screen_room_member_list_role_moderator">"Moderaator"</string>
<string name="screen_room_member_list_role_owner">"Omanik"</string>
<string name="screen_room_member_list_room_members_header_title">"Jututoas osalejad"</string>
<string name="screen_room_member_list_unbanning_user">"Eemaldame suhtluskeelu kasutajalt %1$s"</string>
<string name="screen_room_notification_settings_allow_custom">"Kasuta kohandatud seadistusi"</string>
@ -98,12 +103,14 @@
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mainimiste ja võtmesõnade alusel"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Selles jututoas teavita mind"</string>
<string name="screen_room_roles_and_permissions_admins">"Peakasutajad"</string>
<string name="screen_room_roles_and_permissions_admins_and_owners">"Peakasutajad ja omanikud"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Muuda minu rolli"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Muuda tavaliikmeks"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Muuda moderaatoriks"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Jututoas osalejate modereerimine"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Sõnumid ja sisu"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderaatorid"</string>
<string name="screen_room_roles_and_permissions_owners">"Omanikud"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Õigused"</string>
<string name="screen_room_roles_and_permissions_reset">"Lähtesta õigused"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Kui lähtestad õigused, siis praegune õiguste kombinatsioon läheb kaotsi."</string>

Some files were not shown because too many files have changed in this diff Show more