Merge branch 'release/25.08.0' into main

This commit is contained in:
ganfra 2025-08-01 13:52:47 +02:00
commit 8bf377d65b
636 changed files with 3380 additions and 2459 deletions

View file

@ -4,6 +4,6 @@ appId: ${MAESTRO_APP_ID}
- tapOn:
id: "text_editor"
- inputText: "Hello world!"
- tapOn: "Send"
- tapOn: "Send message"
- hideKeyboard
- takeScreenshot: build/maestro/511-Timeline

View file

@ -6,6 +6,9 @@ appId: ${MAESTRO_APP_ID}
- runFlow: messages/text.yaml
- runFlow: messages/location.yaml
- runFlow: messages/poll.yaml
- runFlow: call/call.yaml
# Restore once the call flow is fixed
#- runFlow: call/call.yaml
- back
- runFlow: ../../assertions/assertHomeDisplayed.yaml

View file

@ -1,3 +1,51 @@
Changes in Element X v25.07.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at v25.07.1 -->
## What's Changed
### 🐛 Bugfixes
* fix ( room list) : rebuild with filteredSummaries to avoid bad state by @ganfra in https://github.com/element-hq/element-x-android/pull/4993
* Keep video rotation metadata when transcoding by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5008
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/4988
### 🧱 Build
* Update Gradle Wrapper from 8.14.2 to 8.14.3 by @ElementBot in https://github.com/element-hq/element-x-android/pull/4985
* Stop ignoring dependencies, but instead set `open-pull-requests-limit to 0 by @bmarty in https://github.com/element-hq/element-x-android/pull/5013
### 📄 Documentation
* Update to the status and clarifications with respect to the legacy app. by @mxandreas in https://github.com/element-hq/element-x-android/pull/5016
### 🚧 In development 🚧
* Home navigation bar fixes by @bmarty in https://github.com/element-hq/element-x-android/pull/4990
* Home screen iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/5003
### Dependency upgrades
* Update dependency io.element.android:compound-android to v25.7.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4984
* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4989
* Update plugin ktlint to v13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4992
* Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.7.1-0.6.x-compat by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4991
* Update haze to v1.6.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4987
* Update dependency com.squareup.okhttp3:okhttp-bom to v5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4979
* Update dependency io.sentry:sentry-android to v8.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4998
* Update dependency com.squareup.okhttp3:okhttp-bom to v5.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/4997
* Update dependency org.maplibre.gl:android-sdk to v11.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5001
* Update dependency com.posthog:posthog-android to v3.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5009
* Update dependency org.maplibre.gl:android-sdk to v11.12.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5006
* Update android.gradle.plugin to v8.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5014
* Update rnkdsh/action-upload-diawi action to v1.5.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5019
* Update wysiwyg to v2.38.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5025
* Update haze to v1.6.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5026
* Update dependency org.matrix.rustcomponents:sdk-android to v25.7.15 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5011
### Others
* Remove bloom effect and replace by linear gradient by @bmarty in https://github.com/element-hq/element-x-android/pull/4926
* misc (a11y) : mark MainActionButton icon as decorative by @ganfra in https://github.com/element-hq/element-x-android/pull/4996
* Make `ContentAvoidingLayoutData` an immutable data class by @jmartinesp in https://github.com/element-hq/element-x-android/pull/4999
* Remove unused composable and cleanup colors by @bmarty in https://github.com/element-hq/element-x-android/pull/5000
* Add a feature flag to reuse the last `pos` value for initial syncs by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5010
* [a11y] Fix several issues around accessibility by @bmarty in https://github.com/element-hq/element-x-android/pull/5007
* Replace video transcoder with Media3 Transformer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5018
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.07.0...v25.07.1
Changes in Element X v25.07.0
=============================

View file

@ -49,7 +49,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.22")
detektPlugins("io.nlopez.compose.rules:detekt:0.4.26")
detektPlugins(project(":tests:detekt-rules"))
}

View file

@ -33,7 +33,7 @@
This doc is a quick introduction about the project and its architecture.
It's aim is to help new developers to understand the overall project and where to start developing.
Its aim is to help new developers to understand the overall project and where to start developing.
Other useful documentation:
@ -157,6 +157,8 @@ Troubleshooting:
- If you get the error `Unsupported class file major version <n>`, try changing your JVM version by setting
`JAVA_HOME` and, if building via Android Studio, "File | Settings | Build, Execution, Deployment | Build Tools | Gradle | Gradle JDK".
You can switch back to using the published version of the SDK by deleting `libraries/rustsdk/matrix-rust-sdk.aar`.
### The Android project
The project should compile out of the box.

View file

@ -149,7 +149,7 @@ That is why clients are able to **process the push rules client side** to decide
As seen previously, App developers don't directly send a push to the end user's device, they use a Push Provider as intermediary. So technically this intermediary is able to read the content of what is sent.
App developers usually mitigate this by sending a `silent notification`, that is a notification with no identifiable data, or with an encrypted payload. When the push is received the app can then synchronise to it's server in order to generate a local notification.
App developers usually mitigate this by sending a `silent notification`, that is a notification with no identifiable data, or with an encrypted payload. When the push is received the app can then synchronise to its server in order to generate a local notification.
### Background processing limitations

View file

@ -0,0 +1,2 @@
Main changes in this version: start support for room v12, bug fixes and general improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -22,7 +22,7 @@ Choose where to host your data - from any public server (the largest free server
Use Element everywhere. Stay in touch wherever you are with fully synchronised message history across all your devices, including on the web at https://app.element.io
<b>Element X is our next-generation app</b>
If youre using the original Element app, its time to try Element X! Its faster, easier to use, and more powerful than the original app. Its better in every way and were adding new features all the time.
If youre using the previous-generation Element Classic app, its time to try Element X! Its faster, easier to use, and more powerful than the classic app. Its better in every way and were adding new features all the time.
The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app.

View file

@ -29,13 +29,13 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
@ -89,10 +89,10 @@ private fun AnalyticsOptInHeader(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
PageTitle(
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 60.dp, bottom = 28.dp),
title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
)
if (state.hasPolicyLink) {

View file

@ -21,6 +21,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
@ -84,6 +86,11 @@ class WebViewAudioManager(
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "${webView.context.packageName}:ProximitySensorCallWakeLock")
}
/**
* Used to ensure that only one coroutine can access the proximity sensor wake lock at a time, preventing re-acquiring or re-releasing it.
*/
private val proximitySensorMutex = Mutex()
/**
* This listener tracks the current communication device and updates the WebView when it changes.
*/
@ -208,8 +215,12 @@ class WebViewAudioManager(
return
}
if (proximitySensorWakeLock?.isHeld == true) {
proximitySensorWakeLock?.release()
coroutineScope.launch {
proximitySensorMutex.withLock {
if (proximitySensorWakeLock?.isHeld == true) {
proximitySensorWakeLock?.release()
}
}
}
audioManager.mode = AudioManager.MODE_NORMAL
@ -397,13 +408,17 @@ class WebViewAudioManager(
expectedNewCommunicationDeviceId = null
@Suppress("WakeLock", "WakeLockTimeout")
if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) {
// If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
proximitySensorWakeLock?.acquire()
} else if (proximitySensorWakeLock?.isHeld == true) {
// If the device is no longer the earpiece, we need to release the wake lock
proximitySensorWakeLock?.release()
coroutineScope.launch {
proximitySensorMutex.withLock {
@Suppress("WakeLock", "WakeLockTimeout")
if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) {
// If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
proximitySensorWakeLock?.acquire()
} else if (proximitySensorWakeLock?.isHeld == true) {
// If the device is no longer the earpiece, we need to release the wake lock
proximitySensorWakeLock?.release()
}
}
}
}

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="call_foreground_service_channel_title_android">"Käimasolev kõne"</string>
<string name="call_foreground_service_channel_title_android">"Pooleliolev kõne"</string>
<string name="call_foreground_service_message_android">"Kõne juurde naasmiseks klõpsa"</string>
<string name="call_foreground_service_title_android">"☎️ Kõne on pooleli"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ei võimalda selles Androidi versioonis Bluetoothi heliseadmete kasutamist. Palun vali mõni muu heliseade."</string>

View file

@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Połączenie w trakcie"</string>
<string name="call_foreground_service_message_android">"Stuknij, aby wrócić do rozmowy"</string>
<string name="call_foreground_service_title_android">"☎️ Rozmowa w toku"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call nie obsługuje korzystania z urządzeń audio Bluetooth w tej wersji Androida. Wybierz inne urządzenie audio."</string>
<string name="screen_incoming_call_subtitle_android">"Przychodzące połączenie Element"</string>
</resources>

View file

@ -30,6 +30,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -213,11 +215,19 @@ private fun RoomNameWithAvatar(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val a11yAvatar = stringResource(CommonStrings.a11y_room_avatar)
UnsavedAvatar(
avatarUri = avatarUri,
avatarSize = AvatarSize.EditRoomDetails,
avatarType = AvatarType.Room(),
modifier = Modifier.clickable(onClick = onAvatarClick),
modifier = Modifier
.clickable(
onClick = onAvatarClick,
onClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.clearAndSetSemantics {
contentDescription = a11yAvatar
},
)
TextField(

View file

@ -31,10 +31,10 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -59,7 +59,7 @@ fun NotificationsOptInView(
.statusBarsPadding()
.fillMaxSize(),
background = { OnboardingBackground() },
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 28.dp)) },
footer = { NotificationsOptInFooter(state) },
) {
NotificationsOptInContent()
@ -70,10 +70,10 @@ fun NotificationsOptInView(
private fun NotificationsOptInHeader(
modifier: Modifier = Modifier,
) {
PageTitle(
IconTitleSubtitleMolecule(
modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title),
subtitle = stringResource(R.string.screen_notification_optin_subtitle),
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
iconStyle = BigIcon.Style.Default(CompoundIcons.NotificationsSolid()),
)
}

View file

@ -24,9 +24,9 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@ -65,10 +65,11 @@ fun ChooseSelfVerificationModeView(
)
},
header = {
PageTitle(
IconTitleSubtitleMolecule(
modifier = Modifier.padding(bottom = 16.dp),
iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()),
title = stringResource(id = R.string.screen_identity_confirmation_title),
subtitle = stringResource(id = R.string.screen_identity_confirmation_subtitle)
subTitle = stringResource(id = R.string.screen_identity_confirmation_subtitle)
)
},
footer = {

View file

@ -274,13 +274,11 @@ private fun HomeScaffold(
floatingActionButton = {
if (state.displayActions) {
FloatingActionButton(
containerColor = ElementTheme.colors.iconPrimary,
onClick = onCreateRoomClick
onClick = onCreateRoomClick,
) {
Icon(
imageVector = CompoundIcons.Plus(),
contentDescription = stringResource(id = R.string.screen_roomlist_a11y_create_message),
tint = ElementTheme.colors.iconOnSolidPrimary,
)
}
}

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Dyw eich allwedd storfa heb ei gydweddu"</string>
<string name="full_screen_intent_banner_message">"Er mwyn sicrhau fyddwch chi ddim yn colli galwad bwysig, newidiwch eich gosodiadau i ganiatáu hysbysiadau sgrin lawn pan fydd eich ffôn wedi\'i gloi."</string>
<string name="full_screen_intent_banner_title">"Gwella profiad eich galwadau"</string>
<string name="screen_home_tab_chats">"Sgyrsiau"</string>
<string name="screen_home_tab_spaces">"Gofodau"</string>
<string name="screen_invites_decline_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y gwahoddiad i ymuno â %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Gwrthod y gwahoddiad"</string>
<string name="screen_invites_decline_direct_chat_message">"Ydych chi\'n siŵr eich bod am wrthod y sgwrs breifat hon gyda %1$s?"</string>

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Ihr Schlüsselspeicher ist nicht synchronisiert"</string>
<string name="full_screen_intent_banner_message">"Damit Sie keine wichtigen Anrufe verpassen, ändern Sie bitte Ihre Einstellungen, so dass das gesperrte Telefon auch Benachrichtigungen im Vollbildmodus erhalten darf."</string>
<string name="full_screen_intent_banner_title">"Verbessere dein Anruferlebnis"</string>
<string name="screen_home_tab_chats">"Chats"</string>
<string name="screen_home_tab_spaces">"Spaces"</string>
<string name="screen_invites_decline_chat_message">"Möchten Sie die Einladung zum Betreten von %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_chat_title">"Einladung ablehnen"</string>
<string name="screen_invites_decline_direct_chat_message">"Möchten Sie diesen privaten Chat mit %1$s wirklich ablehnen?"</string>

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Sinu võtmehoidla pole sünkroonis"</string>
<string name="full_screen_intent_banner_message">"Selleks, et sul ainsamgi tähtis kõne ei jääks märkamata, siis palun muuda oma nutiseadme seadistusi nii, et lukustusvaates oleksid täisekraani mõõtu teavitused."</string>
<string name="full_screen_intent_banner_title">"Sinu tõhusad telefonikõned"</string>
<string name="screen_home_tab_chats">"Vestlused"</string>
<string name="screen_home_tab_spaces">"Kogukonnad"</string>
<string name="screen_invites_decline_chat_message">"Kas sa oled kindel, et soovid keelduda liitumiskutsest: %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Lükka kutse tagasi"</string>
<string name="screen_invites_decline_direct_chat_message">"Kas sa oled kindel, et soovid keelduda privaatsest vestlusest kasutajaga %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">"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_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>

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Le stockage de vos clés nest pas synchronisé"</string>
<string name="full_screen_intent_banner_message">"Afin de ne jamais manquer un appel important, veuillez modifier vos paramètres pour autoriser les notifications en plein écran lorsque votre appareil est verrouillé."</string>
<string name="full_screen_intent_banner_title">"Améliorez votre expérience dappel"</string>
<string name="screen_home_tab_chats">"Discussions"</string>
<string name="screen_home_tab_spaces">"Espaces"</string>
<string name="screen_invites_decline_chat_message">"Êtes-vous sûr de vouloir décliner linvitation à rejoindre %1$s ?"</string>
<string name="screen_invites_decline_chat_title">"Refuser linvitation"</string>
<string name="screen_invites_decline_direct_chat_message">"Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"</string>

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"A kulcstároló nincs szinkronizálva"</string>
<string name="full_screen_intent_banner_message">"Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."</string>
<string name="full_screen_intent_banner_title">"Fokozza a hívásélményét"</string>
<string name="screen_home_tab_chats">"Csevegések"</string>
<string name="screen_home_tab_spaces">"Terek"</string>
<string name="screen_invites_decline_chat_message">"Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Meghívás elutasítása"</string>
<string name="screen_invites_decline_direct_chat_message">"Biztos, hogy elutasítja ezt a privát csevegést vele: %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="screen_home_tab_chats">"Pokalbiai"</string>
<string name="screen_home_tab_spaces">"Erdvės"</string>
<string name="screen_invites_decline_chat_message">"Ar tikrai norite atmesti kvietimą prisijungti prie %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Atmesti kvietimą"</string>
<string name="screen_invites_decline_direct_chat_message">"Ar tikrai norite atmesti šį privatų pokalbį su %1$s ?"</string>

View file

@ -1,5 +1,8 @@
<?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">"Wyłącz optymalizację baterii dla tej aplikacji, aby upewnić się, że wszystkie powiadomienia są odbierane."</string>
<string name="banner_battery_optimization_submit_android">"Wyłącz optymalizację"</string>
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
<string name="banner_set_up_recovery_content">"Wygeneruj nowy klucz przywracania, którego można użyć do przywrócenia historii wiadomości szyfrowanych w przypadku utraty dostępu do swoich urządzeń."</string>
<string name="banner_set_up_recovery_submit">"Skonfiguruj przywracanie"</string>
<string name="banner_set_up_recovery_title">"Skonfiguruj przywracanie"</string>
@ -9,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Magazyn kluczy nie jest zsynchronizowany"</string>
<string name="full_screen_intent_banner_message">"Upewnij się, że nie pominiesz żadnego połączenia. Zmień swoje ustawienia i zezwól na powiadomienia na blokadzie ekranu."</string>
<string name="full_screen_intent_banner_title">"Popraw jakość swoich rozmów"</string>
<string name="screen_home_tab_chats">"Czaty"</string>
<string name="screen_home_tab_spaces">"Przestrzenie"</string>
<string name="screen_invites_decline_chat_message">"Czy na pewno chcesz odrzucić zaproszenie dołączenia do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odrzuć zaproszenie"</string>
<string name="screen_invites_decline_direct_chat_message">"Czy na pewno chcesz odrzucić rozmowę prywatną z %1$s?"</string>
@ -18,6 +23,7 @@
<string name="screen_migration_message">"Jest to jednorazowy proces, dziękujemy za czekanie."</string>
<string name="screen_migration_title">"Konfigurowanie Twojego konta."</string>
<string name="screen_roomlist_a11y_create_message">"Utwórz nową rozmowę lub pokój"</string>
<string name="screen_roomlist_clear_filters">"Wyczyść filtry"</string>
<string name="screen_roomlist_empty_message">"Wyślij komuś wiadomość, aby rozpocząć."</string>
<string name="screen_roomlist_empty_title">"Brak czatów."</string>
<string name="screen_roomlist_filter_favourites">"Ulubione"</string>
@ -40,6 +46,7 @@ Nie masz żadnych nieprzeczytanych wiadomości!"</string>
<string name="screen_roomlist_main_space_title">"Wszystkie czaty"</string>
<string name="screen_roomlist_mark_as_read">"Oznacz jako przeczytane"</string>
<string name="screen_roomlist_mark_as_unread">"Oznacz jako nieprzeczytane"</string>
<string name="screen_roomlist_tombstoned_room_description">"Ten pokój został ulepszony"</string>
<string name="session_verification_banner_message">"Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości."</string>
<string name="session_verification_banner_title">"Potwierdź, że to Ty"</string>
</resources>

View file

@ -12,6 +12,8 @@
<string name="confirm_recovery_key_banner_title">"Vaše úložisko kľúčov nie je synchronizované"</string>
<string name="full_screen_intent_banner_message">"Aby ste už nikdy nezmeškali dôležitý hovor, zmeňte svoje nastavenia a povoľte upozornenia na celú obrazovku, keď je váš telefón uzamknutý."</string>
<string name="full_screen_intent_banner_title">"Vylepšite svoj zážitok z hovoru"</string>
<string name="screen_home_tab_chats">"Konverzácie"</string>
<string name="screen_home_tab_spaces">"Priestory"</string>
<string name="screen_invites_decline_chat_message">"Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odmietnuť pozvanie"</string>
<string name="screen_invites_decline_direct_chat_message">"Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Liitu jututoaga"</string>
<string name="screen_join_room_join_restricted_message">"Selle jututoaga liitumiseks sa vajad kutset või pead juba olema kogukonna liige."</string>
<string name="screen_join_room_knock_action">"Liitumiseks koputa jututoa uksele"</string>
<string name="screen_join_room_knock_message_characters_count">"Lubatud tähemärke: %1$d / %2$d"</string>
<string name="screen_join_room_knock_message_description">"Selgitus (kui soovid lisada)"</string>
<string name="screen_join_room_knock_sent_description">"Kui sinu liitumispalvega ollakse nõus, siis saad kutse jututoaga liitumiseks."</string>
<string name="screen_join_room_knock_sent_title">"Liitumispalve on saadetud"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Csatlakozás a szobához"</string>
<string name="screen_join_room_join_restricted_message">"A csatlakozáshoz meghívásra vagy tértagságra lehet szüksége."</string>
<string name="screen_join_room_knock_action">"Kopogtasson a csatlakozáshoz"</string>
<string name="screen_join_room_knock_message_characters_count">"Engedélyezett karakterek: %1$d / %2$d"</string>
<string name="screen_join_room_knock_message_description">"Üzenet (nem kötelező)"</string>
<string name="screen_join_room_knock_sent_description">"Ha a kérését elfogadják, meghívót kap a szobához való csatlakozáshoz."</string>
<string name="screen_join_room_knock_sent_title">"Csatlakozási kérés elküldve"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Pripojiť sa do miestnosti"</string>
<string name="screen_join_room_join_restricted_message">"Možno budete musieť byť pozvaní alebo byť členom priestoru, aby ste sa mohli pripojiť."</string>
<string name="screen_join_room_knock_action">"Zaklopaním sa pripojíte"</string>
<string name="screen_join_room_knock_message_characters_count">"Povolené znaky %1$d z %2$d"</string>
<string name="screen_join_room_knock_message_description">"Správa (voliteľné)"</string>
<string name="screen_join_room_knock_sent_description">"Ak bude vaša žiadosť prijatá, dostanete pozvánku na vstup do miestnosti."</string>
<string name="screen_join_room_knock_sent_title">"Žiadosť o pripojenie bola odoslaná"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_join_room_join_action">"Join room"</string>
<string name="screen_join_room_join_restricted_message">"You may need to be invited or be a member of a space in order to join."</string>
<string name="screen_join_room_knock_action">"Send request to join"</string>
<string name="screen_join_room_knock_message_characters_count">"Allowed characters %1$d of %2$d"</string>
<string name="screen_join_room_knock_message_description">"Message (optional)"</string>
<string name="screen_join_room_knock_sent_description">"You will receive an invite to join the room if your request is accepted."</string>
<string name="screen_join_room_knock_sent_title">"Request to join sent"</string>

View file

@ -0,0 +1,50 @@
/*
* 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.location.impl.common.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: See design in https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=3426-141111
*/
@Composable
internal fun LocationFloatingActionButton(
isMapCenteredOnUser: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FloatingActionButton(
shape = FloatingActionButtonDefaults.smallShape,
containerColor = ElementTheme.colors.bgCanvasDefault,
contentColor = ElementTheme.colors.iconPrimary,
onClick = onClick,
modifier = modifier
// Note: design is 40dp, but min is 48 for accessibility.
.size(48.dp),
) {
val iconImage = if (isMapCenteredOnUser) {
CompoundIcons.LocationNavigatorCentred()
} else {
CompoundIcons.LocationNavigator()
}
Icon(
imageVector = iconImage,
contentDescription = stringResource(CommonStrings.a11y_move_the_map_to_my_location),
)
}
}

View file

@ -26,9 +26,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.room.message.replyInThread
import io.element.android.libraries.matrix.ui.messages.reply.eventId
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.launch
@ -103,17 +100,7 @@ class SendLocationPresenter @Inject constructor(
mode: SendLocationState.Mode,
) {
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
val replyParams = replyMode?.replyToDetails?.let { details ->
if (replyMode.inThread) {
replyInThread(details.eventId())
} else {
ReplyParameters(
inReplyToEventId = details.eventId(),
enforceThreadReply = false,
replyWithinThread = false
)
}
}
val inReplyToEventId = replyMode?.eventId
when (mode) {
SendLocationState.Mode.PinLocation -> {
val geoUri = event.cameraPosition.toGeoUri()
@ -123,7 +110,7 @@ class SendLocationPresenter @Inject constructor(
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = AssetType.PIN,
replyParameters = replyParams,
inReplyToEventId = inReplyToEventId,
)
analyticsService.capture(
Composer(
@ -142,7 +129,7 @@ class SendLocationPresenter @Inject constructor(
description = null,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
assetType = AssetType.SENDER,
replyParameters = replyParams,
inReplyToEventId = inReplyToEventId,
)
analyticsService.capture(
Composer(

View file

@ -30,7 +30,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberTileStyleUrl
@ -38,11 +37,11 @@ import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -189,17 +188,13 @@ fun SendLocationView(
tint = Color.Unspecified,
modifier = Modifier.centerBottomEdge(this),
)
FloatingActionButton(
LocationFloatingActionButton(
isMapCenteredOnUser = state.mode == SendLocationState.Mode.SenderLocation,
onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 72.dp + navBarPadding),
) {
when (state.mode) {
SendLocationState.Mode.PinLocation -> Icon(imageVector = CompoundIcons.LocationNavigator(), contentDescription = null)
SendLocationState.Mode.SenderLocation -> Icon(imageVector = CompoundIcons.LocationNavigatorCentred(), contentDescription = null)
}
}
.padding(end = 18.dp, bottom = 72.dp + navBarPadding),
)
}
}
}

View file

@ -27,10 +27,10 @@ import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -118,14 +118,10 @@ fun ShowLocationView(
)
},
floatingActionButton = {
FloatingActionButton(
LocationFloatingActionButton(
isMapCenteredOnUser = state.isTrackMyLocation,
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
) {
when (state.isTrackMyLocation) {
false -> Icon(imageVector = CompoundIcons.LocationNavigator(), contentDescription = null)
true -> Icon(imageVector = CompoundIcons.LocationNavigatorCentred(), contentDescription = null)
}
}
)
},
) { paddingValues ->
Column(

View file

@ -20,9 +20,9 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
@ -264,7 +264,7 @@ class SendLocationPresenterTest {
@Test
fun `share sender location`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, ReplyParameters?, Result<Unit>> { _, _, _, _, _, _ ->
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
@ -328,7 +328,7 @@ class SendLocationPresenterTest {
@Test
fun `share pin location`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, ReplyParameters?, Result<Unit>> { _, _, _, _, _, _ ->
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(
@ -392,7 +392,7 @@ class SendLocationPresenterTest {
@Test
fun `composer context passes through analytics`() = runTest {
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, ReplyParameters?, Result<Unit>> { _, _, _, _, _, _ ->
val sendLocationResult = lambdaRecorder<String, String, String?, Int?, AssetType?, EventId?, Result<Unit>> { _, _, _, _, _, _ ->
Result.success(Unit)
}
val joinedRoom = FakeJoinedRoom(

View file

@ -32,7 +32,7 @@ import javax.inject.Inject
/**
* This class is responsible for managing the login flow, including handling OIDC actions and
* submitting login requests.
* It's an helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter]
* It's a helper to avoid code duplication. It is used by [OnBoardingPresenter], [ConfirmAccountProviderPresenter]
* and [ChooseAccountProviderPresenter].
*/
class LoginHelper @Inject constructor(

View file

@ -83,7 +83,7 @@ Prøv at logge ind manuelt, eller scan QR-koden med en anden enhed."</string>
<string name="screen_qr_code_login_start_over_button">"Begynd forfra"</string>
<string name="screen_qr_code_login_unknown_error_description">"Der opstod en uventet fejl. Prøv venligst igen."</string>
<string name="screen_qr_code_login_verify_code_loading">"Venter på din anden enhed"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Din kontoudbyder kan bede om følgende kode for at bekræfte login."</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Din kontoudbyder kan bede om følgende kode for at verificere login\'et."</string>
<string name="screen_qr_code_login_verify_code_title">"Din bekræftelseskode"</string>
<string name="screen_server_confirmation_change_server">"Skift kontoudbyder"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"En privat server til Element-medarbejdere."</string>

View file

@ -13,6 +13,8 @@
<string name="screen_change_account_provider_other">"Muu teenusepakkuja"</string>
<string name="screen_change_account_provider_subtitle">"Kasuta erinevat teenusepakkujat, milleks võib olla ka sinu oma server või töökoha hallatav server."</string>
<string name="screen_change_account_provider_title">"Muuda teenusepakkujat"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s koduserver eeldab Element Pro rakenduse kasutamist. Palun laadi ta alla rakendustepoest."</string>
<string name="screen_change_server_error_element_pro_required_title">"Vajalik on Element Pro"</string>
<string name="screen_change_server_error_invalid_homeserver">"Me ei suutnud luuaühendust selle koduserveriga. Palun kontrolli, kas koduserveri aadress on õige. Kui aadress on õige, siis täiendavat teavet oskab sulle anda koduserveri haldaja."</string>
<string name="screen_change_server_error_invalid_well_known">"Server pole saadaval vea tõttu well-known failis:
%1$s"</string>

View file

@ -13,6 +13,8 @@
<string name="screen_change_account_provider_other">"Autres"</string>
<string name="screen_change_account_provider_subtitle">"Utilisez un autre fournisseur de compte, tel que votre propre serveur privé ou un serveur professionnel."</string>
<string name="screen_change_account_provider_title">"Changer de fournisseur de compte"</string>
<string name="screen_change_server_error_element_pro_required_message">"Lapplication Element Pro est requise sur %1$s. Veuillez la télécharger depuis le store."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro est requis"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nous navons pas pu atteindre ce serveur daccueil. Vérifiez que vous avez correctement saisi lURL du serveur daccueil. Si lURL est correcte, contactez ladministrateur de votre serveur daccueil pour obtenir de laide."</string>
<string name="screen_change_server_error_invalid_well_known">"Ce fournisseur de compte nest pas disponible en raison dun problème dans le fichier .well-known:
%1$s"</string>

View file

@ -13,6 +13,8 @@
<string name="screen_change_account_provider_other">"Egyéb"</string>
<string name="screen_change_account_provider_subtitle">"Másik fiókszolgáltató, például a saját privát kiszolgáló vagy egy munkahelyi fiók használata."</string>
<string name="screen_change_account_provider_title">"Fiókszolgáltató módosítása"</string>
<string name="screen_change_server_error_element_pro_required_message">"Az Element Pro alkalmazás szükséges a következőn: %1$s. Töltse le az áruházból."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro szükséges"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nem sikerült elérni ezt a Matrix-kiszolgálót. Ellenőrizze, hogy helyesen adta-e meg a Matrix-kiszolgáló webcímét. Ha a webcím helyes, akkor további segítségért lépjen kapcsolatba a Matrix-kiszolgáló adminisztrátorával."</string>
<string name="screen_change_server_error_invalid_well_known">"A kiszolgáló a well-known fájl problémája miatt nem érhető el:
%1$s"</string>
@ -34,6 +36,7 @@
<string name="screen_login_subtitle">"A Matrix egy nyitott hálózat a biztonságos, decentralizált kommunikációhoz."</string>
<string name="screen_login_title">"Örülünk, hogy visszatért!"</string>
<string name="screen_login_title_with_homeserver">"Bejelentkezés ide: %1$s"</string>
<string name="screen_onboarding_app_version">"Verzió: %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Kézi bejelentkezés"</string>
<string name="screen_onboarding_sign_in_to">"Bejelentkezés ide: %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Bejelentkezés QR-kóddal"</string>

View file

@ -13,7 +13,7 @@
<string name="screen_change_server_error_invalid_homeserver">"Nepavyko pasiekti šio serverio. Patikrinkite, ar teisingai įvedėte serverio URL. Jei URL yra teisingas, susisiekite su serverio administracija dėl tolimesnės pagalbos."</string>
<string name="screen_change_server_form_header">"Serverio URL"</string>
<string name="screen_change_server_subtitle">"Koks yra Jūsų serverio adresas?"</string>
<string name="screen_create_account_title">"Sukurti paskyrą"</string>
<string name="screen_create_account_title">"Kurti paskyrą"</string>
<string name="screen_login_error_deactivated_account">"Ši paskyra buvo išjungta."</string>
<string name="screen_login_error_invalid_credentials">"Neteisingas vartotojo vardas ir (arba) slaptažodis"</string>
<string name="screen_login_error_invalid_user_id">"Tai nėra tinkamas vartotojo vardas. Reikalingas formatas: \'@vartotojas:serveris.org\'"</string>
@ -22,10 +22,13 @@
<string name="screen_login_subtitle">"Matrix yra atviras tinklas, skirtas saugiam, decentralizuotam bendravimui."</string>
<string name="screen_login_title">"Sveiki sugrįžę!"</string>
<string name="screen_login_title_with_homeserver">"Prisijungti prie %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Prisijunkite rankiniu būdu"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Prisijunkite naudodami QR kodą"</string>
<string name="screen_onboarding_sign_up">"Sukurti paskyrą"</string>
<string name="screen_onboarding_welcome_subtitle">"Sveiki atvykę į %1$s. Įkrautas greitumui ir paprastumui."</string>
<string name="screen_onboarding_app_version">"%1$s versija"</string>
<string name="screen_onboarding_sign_in_manually">"Prisijungti rankiniu būdu"</string>
<string name="screen_onboarding_sign_in_to">"Prisijungti prie %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Prisijungti su QR kodu"</string>
<string name="screen_onboarding_sign_up">"Kurti paskyrą"</string>
<string name="screen_onboarding_welcome_message">"Sveiki atvykę į sparčiausią „%1$s“ kada nors. Pagerintas spartai ir paprastumui."</string>
<string name="screen_onboarding_welcome_subtitle">"Sveiki atvykę į „%1$s“. Pagerintas spartai ir paprastumui."</string>
<string name="screen_onboarding_welcome_title">"Būkite savo elemente"</string>
<string name="screen_server_confirmation_change_server">"Keisti paskyros teikėją"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Privatus serveris “Element” darbuotojams."</string>

View file

@ -13,11 +13,15 @@
<string name="screen_change_account_provider_other">"Inne"</string>
<string name="screen_change_account_provider_subtitle">"Użyj innego dostawcy konta, takiego jak własny serwer lub konta służbowego."</string>
<string name="screen_change_account_provider_title">"Zmień dostawcę konta"</string>
<string name="screen_change_server_error_element_pro_required_message">"Wymagana jest aplikacja Element Pro na %1$s. Znajdziesz ją w sklepie z aplikacjami."</string>
<string name="screen_change_server_error_element_pro_required_title">"Wymagany jest Element Pro"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nie mogliśmy połączyć się z tym serwerem domowym. Sprawdź, czy adres URL serwera został wprowadzony poprawnie. Jeśli adres URL jest poprawny, skontaktuj się z administratorem serwera w celu uzyskania dalszej pomocy."</string>
<string name="screen_change_server_error_invalid_well_known">"Serwer nie jest dostępny z powodu problemu pliku .well-known:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Wybrany dostawca konta nie wspiera synchronizacji przesuwnej. Wymagana jest aktualizacja serwera %1$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%1$s nie posiada zezwolenia na dołączenie do %2$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver_content">"Aplikacja została skonfigurowana tak, aby zezwalała na: %1$s."</string>
<string name="screen_change_server_error_unauthorized_homeserver_title">"Dostawca konta %1$s jest niedozwolony."</string>
<string name="screen_change_server_form_header">"URL serwera domowego"</string>
<string name="screen_change_server_form_notice">"Wprowadź adres domeny."</string>
<string name="screen_change_server_subtitle">"Jaki jest adres Twojego serwera?"</string>
@ -32,6 +36,7 @@
<string name="screen_login_subtitle">"Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."</string>
<string name="screen_login_title">"Witaj ponownie!"</string>
<string name="screen_login_title_with_homeserver">"Zaloguj się do %1$s"</string>
<string name="screen_onboarding_app_version">"Wersja %1$s"</string>
<string name="screen_onboarding_sign_in_manually">"Zaloguj się ręcznie"</string>
<string name="screen_onboarding_sign_in_to">"Zaloguj się do %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Zaloguj się za pomocą kodu QR"</string>
@ -87,5 +92,6 @@ Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu."</st
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji."</string>
<string name="screen_server_confirmation_message_register">"Tutaj będą przechowywane Twoje konwersacje - w podobnej formie jak wiadomości widnieją na skrzynce e-mail."</string>
<string name="screen_server_confirmation_title_login">"Zamierzasz się zalogować do %1$s"</string>
<string name="screen_server_confirmation_title_picker_mode">"Wybierz dostawcę konta"</string>
<string name="screen_server_confirmation_title_register">"Zamierzasz utworzyć konto na %1$s"</string>
</resources>

View file

@ -13,6 +13,8 @@
<string name="screen_change_account_provider_other">"Iný"</string>
<string name="screen_change_account_provider_subtitle">"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."</string>
<string name="screen_change_account_provider_title">"Zmeniť poskytovateľa účtu"</string>
<string name="screen_change_server_error_element_pro_required_message">"Aplikácia Element Pro je potrebná na %1$s Stiahnite si ju z obchodu."</string>
<string name="screen_change_server_error_element_pro_required_title">"Vyžaduje sa Element Pro"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nemohli sme sa spojiť s týmto domovským serverom. Skontrolujte prosím, či ste zadali URL adresu domovského servera správne. Ak je adresa URL správna, kontaktujte svoj domovský server pre ďalšiu pomoc."</string>
<string name="screen_change_server_error_invalid_well_known">"Server nie je k dispozícii kvôli problému v známom súbore:
%1$s"</string>

View file

@ -13,6 +13,8 @@
<string name="screen_change_account_provider_other">"Other"</string>
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
<string name="screen_change_account_provider_title">"Change account provider"</string>
<string name="screen_change_server_error_element_pro_required_message">"The Element Pro app is required on %1$s. Please download it from the store."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro required"</string>
<string name="screen_change_server_error_invalid_homeserver">"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."</string>
<string name="screen_change_server_error_invalid_well_known">"Server isn\'t available due to an issue in the .well-known file:
%1$s"</string>

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Ar tikrai norite atsijungti?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Atsijungti"</string>
<string name="screen_signout_confirmation_dialog_title">"Atsijungti"</string>
<string name="screen_signout_in_progress_dialog_content">"Atsijungiama…"</string>
<string name="screen_signout_preference_item">"Atsijungti"</string>
</resources>

View file

@ -31,9 +31,9 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
@ -129,14 +129,19 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = !useSendQueue,
replyParameters = null,
inReplyToEventId = null,
)
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
}
}
}
AttachmentsPreviewEvents.CancelAndDismiss -> {
// Cancel media preprocessing and sending
preprocessMediaJob?.cancel()
// If we couldn't send the pre-processed media, remove it
mediaSender.cleanUp()
ongoingSendAttachmentJob.value?.cancel()
// Dismiss the screen
@ -240,7 +245,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption: String?,
sendActionState: MutableState<SendActionState>,
dismissAfterSend: Boolean,
replyParameters: ReplyParameters?,
inReplyToEventId: EventId?,
) = runCatchingExceptions {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
@ -256,7 +261,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
caption = caption,
formattedCaption = null,
progressCallback = progressCallback,
replyParameters = replyParameters,
inReplyToEventId = inReplyToEventId,
).getOrThrow()
}.fold(
onSuccess = {

View file

@ -66,7 +66,7 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState)
}
BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) {
postCancel()
}

View file

@ -55,7 +55,6 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@ -466,12 +465,7 @@ class MessageComposerPresenter @AssistedInject constructor(
body = message.markdown,
htmlBody = message.html,
intentionalMentions = message.intentionalMentions,
replyParameters = ReplyParameters(
inReplyToEventId = eventId,
enforceThreadReply = inThread,
// This should be false until we add a way to make a reply in a thread an explicit reply to the provided eventId
replyWithinThread = false,
),
repliedToEventId = eventId,
)
}
}

View file

@ -157,7 +157,7 @@ internal fun SuggestionsPickerViewPreview() {
powerLevel = 0L,
normalizedPowerLevel = 0L,
isIgnored = false,
role = RoomMember.Role.USER,
role = RoomMember.Role.User,
membershipChangeReason = null,
)
val anAlias = remember { RoomAlias("#room:domain.org") }

View file

@ -366,7 +366,7 @@ private fun JumpToBottomButton(
shape = CircleShape,
modifier = Modifier.size(36.dp),
containerColor = ElementTheme.colors.bgSubtleSecondary,
contentColor = ElementTheme.colors.iconSecondary
contentColor = ElementTheme.colors.iconSecondary,
) {
Icon(
modifier = Modifier

View file

@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -20,7 +21,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
@ -42,7 +42,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
@ -64,7 +63,7 @@ fun MessageEventBubble(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit = {},
content: @Composable BoxScope.() -> Unit = {},
) {
fun bubbleShape(): Shape {
val topLeftCorner = if (state.cutTopStart) 0.dp else BUBBLE_RADIUS
@ -117,9 +116,12 @@ fun MessageEventBubble(
BoxWithConstraints(
modifier = modifier
.graphicsLayer {
shape = bubbleShape
clip = true
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawRect(backgroundBubbleColor)
drawContent()
if (state.cutTopStart) {
drawCircle(
@ -137,7 +139,7 @@ fun MessageEventBubble(
// when content width is low.
contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Surface(
Box(
modifier = Modifier
.testTag(TestTags.messageBubble)
.widthIn(
@ -146,11 +148,8 @@ fun MessageEventBubble(
.toInt()
.toDp()
)
.clip(bubbleShape)
.then(clickableModifier),
color = backgroundBubbleColor,
shape = bubbleShape,
content = content
content = content,
)
}
}

View file

@ -26,7 +26,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.allBooleans
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
@ -92,7 +91,7 @@ internal fun TimelineItemRoomBeginningViewPreview() = ElementPreview {
onPredecessorRoomClick = {},
)
TimelineItemRoomBeginningView(
predecessorRoom = PredecessorRoom(RoomId("!roomId:matrix.org"), EventId("\$eventId:matrix.org")),
predecessorRoom = PredecessorRoom(RoomId("!roomId:matrix.org")),
roomName = "Room Name",
isDm = isDm,
onPredecessorRoomClick = {},

View file

@ -35,14 +35,30 @@
<string name="screen_room_timeline_less_reactions">"Pokaż mniej"</string>
<string name="screen_room_timeline_message_copied">"Skopiowano wiadomość"</string>
<string name="screen_room_timeline_no_permission_to_post">"Nie masz uprawnień, aby pisać w tym pokoju"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d członek zareagował z %2$s"</item>
<item quantity="few">"%1$d członków zareagowało z %2$s"</item>
<item quantity="many">"%1$d członków zareagowało z %2$s"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"Ty i %1$d członek zareagowaliście z %2$s"</item>
<item quantity="few">"Ty i %1$d członków zareagowaliście z %2$s"</item>
<item quantity="many">"Ty i %1$d członków zareagowaliście z %2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"Zareagowałeś z %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Pokaż mniej"</string>
<string name="screen_room_timeline_reactions_show_more">"Pokaż więcej"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Pokaż podsumowanie reakcji"</string>
<string name="screen_room_timeline_read_marker_title">"Nowe"</string>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d zmiana pokoju"</item>
<item quantity="few">"%1$d zmiany pokoju"</item>
<item quantity="many">"%1$d zmian pokoju"</item>
</plurals>
<string name="screen_room_timeline_tombstoned_room_action">"Przejdź do nowego pokoju"</string>
<string name="screen_room_timeline_tombstoned_room_message">"Ten pokój został zmieniony i nie jest już aktywny"</string>
<string name="screen_room_timeline_upgraded_room_action">"Zobacz stare wiadomości"</string>
<string name="screen_room_timeline_upgraded_room_message">"Ten pokój jest kontynuacją innego pokoju"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s i %3$d inny"</item>
<item quantity="few">"%1$s, %2$s i %3$d innych"</item>

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
@ -30,7 +31,6 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.A_CAPTION
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
@ -108,7 +108,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send media success scenario`() = runTest {
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeJoinedRoom(
@ -123,8 +123,10 @@ class AttachmentsPreviewPresenterTest {
},
)
val onDoneListener = lambdaRecorder<Unit> { }
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
@ -143,13 +145,14 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1)
}
}
@Test
fun `present - send media after pre-processing success scenario`() = runTest {
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeJoinedRoom(
@ -159,11 +162,10 @@ class AttachmentsPreviewPresenterTest {
)
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val mediaPreProcessor = FakeMediaPreProcessor(processLatch)
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor(
processLatch = processLatch,
),
mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
@ -181,13 +183,14 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1)
}
}
@Test
fun `present - send media before pre-processing success scenario`() = runTest {
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeJoinedRoom(
@ -197,11 +200,10 @@ class AttachmentsPreviewPresenterTest {
)
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val mediaPreProcessor = FakeMediaPreProcessor(processLatch)
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor(
processLatch = processLatch,
),
mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
@ -219,6 +221,7 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1)
}
}
@ -279,7 +282,9 @@ class AttachmentsPreviewPresenterTest {
fun `present - cancel scenario`() = runTest {
val onDoneListener = lambdaRecorder<Unit> { }
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
mediaPreProcessor = mediaPreProcessor,
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
onDoneListener = { onDoneListener() },
)
@ -293,13 +298,14 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
deleteCallback.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
assertThat(mediaPreProcessor.cleanUpCallCount).isEqualTo(1)
}
}
@Test
fun `present - send image with caption success scenario`() = runTest {
val sendImageResult =
lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
lambdaRecorder { _: File, _: File?, _: ImageInfo, _: String?, _: String?, _: ProgressCallback?, _: EventId? ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@ -343,7 +349,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send video with caption success scenario`() = runTest {
val sendVideoResult =
lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: ProgressCallback?, _: ReplyParameters? ->
lambdaRecorder { _: File, _: File?, _: VideoInfo, _: String?, _: String?, _: ProgressCallback?, _: EventId? ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@ -387,7 +393,7 @@ class AttachmentsPreviewPresenterTest {
@Test
fun `present - send audio with caption success scenario`() = runTest {
val sendAudioResult =
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
lambdaRecorder<File, AudioInfo, String?, String?, ProgressCallback?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val mediaPreProcessor = FakeMediaPreProcessor().apply {
@ -429,7 +435,7 @@ class AttachmentsPreviewPresenterTest {
fun `present - send media failure scenario without media queue`() = runTest {
val failure = MediaPreProcessor.Failure(null)
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.failure(failure)
}
val room = FakeJoinedRoom(
@ -460,7 +466,7 @@ class AttachmentsPreviewPresenterTest {
fun `present - send media failure scenario with media queue`() = runTest {
val failure = MediaPreProcessor.Failure(null)
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _, _ ->
Result.failure(failure)
}
val onDoneListenerResult = lambdaRecorder<Unit> {}

View file

@ -48,7 +48,6 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@ -610,7 +609,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
Result.success(Unit)
}
val timeline = FakeTimeline().apply {
@ -1100,7 +1099,7 @@ class MessageComposerPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - send messages with intentional mentions`() = runTest {
val replyMessageLambda = lambdaRecorder { _: ReplyParameters, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
val replyMessageLambda = lambdaRecorder { _: EventId?, _: String, _: String?, _: List<IntentionalMention>, _: Boolean ->
Result.success(Unit)
}
val editMessageLambda = lambdaRecorder { _: EventOrTransactionId, _: String, _: String?, _: List<IntentionalMention> ->

View file

@ -710,11 +710,7 @@ class TimelinePresenterTest {
@Test
fun `present - timeline room info includes predecessor room when room has predecessor`() = runTest {
val predecessorRoomId = RoomId("!predecessor:server.org")
val predecessorEventId = EventId("\$predecessorEvent:server.org")
val predecessorRoom = PredecessorRoom(
roomId = predecessorRoomId,
lastEventId = predecessorEventId
)
val predecessorRoom = PredecessorRoom(roomId = predecessorRoomId)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
@ -730,7 +726,6 @@ class TimelinePresenterTest {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull()
assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId)
assertThat(initialState.timelineRoomInfo.predecessorRoom?.lastEventId).isEqualTo(predecessorEventId)
}
}

View file

@ -19,9 +19,9 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.room.message.ReplyParameters
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
@ -63,7 +63,7 @@ class VoiceMessageComposerPresenterTest {
)
private val analyticsService = FakeAnalyticsService()
private val sendVoiceMessageResult =
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, ReplyParameters?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
lambdaRecorder<File, AudioInfo, List<Float>, ProgressCallback?, EventId?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
private val joinedRoom = FakeJoinedRoom(

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="a11y_polls_will_remove_selection">"Bydd yn dileu\'r dewis blaenorol"</string>
<string name="a11y_polls_winning_answer">"Dyma\'r ateb buddugol"</string>
</resources>

View file

@ -4,5 +4,6 @@
<item quantity="one">"%1$d Prozent der Stimmen insgesamt"</item>
<item quantity="other">"%1$d Prozent der Gesamtstimmen"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Entfernt die vorherige Auswahl"</string>
<string name="a11y_polls_winning_answer">"Das ist die Gewinnerantwort"</string>
</resources>

View file

@ -4,5 +4,6 @@
<item quantity="one">"%1$d protsent kõikidest antud häältest"</item>
<item quantity="other">"%1$d protsenti kõikidest antud häältest"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"See kustutab eelmise valiku"</string>
<string name="a11y_polls_winning_answer">"See vastus võitis"</string>
</resources>

View file

@ -4,5 +4,6 @@
<item quantity="one">"%1$d pour cent du total des votes"</item>
<item quantity="other">"%1$d pour cent du total des votes"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Supprimera la sélection précédente"</string>
<string name="a11y_polls_winning_answer">"Cest la réponse gagnante"</string>
</resources>

View file

@ -4,5 +4,6 @@
<item quantity="one">"az összes szavazat %1$d százaléka"</item>
<item quantity="other">"az összes szavazat %1$d százaléka"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Eltávolítja a korábbi kijelölést"</string>
<string name="a11y_polls_winning_answer">"Ez a győztes válasz"</string>
</resources>

View file

@ -0,0 +1,10 @@
<?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 procent wszystkich głosów"</item>
<item quantity="few">"%1$d procenty wszystkich głosów"</item>
<item quantity="many">"%1$d procent wszystkich głosów"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Spowoduje to usunięcie poprzedniego zaznaczenia"</string>
<string name="a11y_polls_winning_answer">"Zwycięska odpowiedź"</string>
</resources>

View file

@ -5,5 +5,6 @@
<item quantity="few">"%1$d percentá z celkového počtu hlasov"</item>
<item quantity="other">"%1$d percent z celkového počtu hlasov"</item>
</plurals>
<string name="a11y_polls_will_remove_selection">"Odstráni predchádzajúci výber"</string>
<string name="a11y_polls_winning_answer">"Toto je víťazná odpoveď"</string>
</resources>

View file

@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Ukryj głosy"</string>
<string name="screen_create_poll_answer_hint">"Opcja %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Zmiany nie zostały zapisane. Czy na pewno chcesz wrócić?"</string>
<string name="screen_create_poll_delete_option_a11y">"Usuń opcję %1$s"</string>
<string name="screen_create_poll_question_desc">"Pytanie lub temat"</string>
<string name="screen_create_poll_question_hint">"Czego dotyczy ankieta?"</string>
<string name="screen_create_poll_title">"Utwórz ankietę"</string>

View file

@ -10,6 +10,7 @@
<string name="screen_bug_report_error_description_too_short">"Opis jest zbyt krótki, podaj więcej szczegółów na temat tego co się stało. Dzięki!"</string>
<string name="screen_bug_report_include_crash_logs">"Wyślij logi awarii"</string>
<string name="screen_bug_report_include_logs">"Zezwól na logi"</string>
<string name="screen_bug_report_include_logs_error">"Twoje dzienniki są zbyt duże, więc nie można ich uwzględnić w tym raporcie. Prześlij je do nas w inny sposób."</string>
<string name="screen_bug_report_include_screenshot">"Wyślij zrzut ekranu"</string>
<string name="screen_bug_report_logs_description">"Logi zostaną dołączone do Twojej wiadomości, aby upewnić się, że wszystko działa poprawnie. Aby wysłać wiadomość bez logów, wyłącz to ustawienie."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s uległ awarii podczas ostatniego użycia. Czy chcesz przesłać nam raport o awarii?"</string>

View file

@ -69,7 +69,7 @@ fun aDmRoomMember(
powerLevel: Long = 0,
normalizedPowerLevel: Long = powerLevel,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null,
) = RoomMember(
userId = userId,

View file

@ -13,9 +13,10 @@ import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsV
import io.element.android.services.analytics.api.AnalyticsService
internal fun RoomMember.Role.toAnalyticsMemberRole(): RoomModeration.Role = when (this) {
RoomMember.Role.ADMIN -> RoomModeration.Role.Administrator
RoomMember.Role.MODERATOR -> RoomModeration.Role.Moderator
RoomMember.Role.USER -> RoomModeration.Role.User
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
}
internal fun analyticsMemberRoleForPowerLevel(powerLevel: Long): RoomModeration.Role {

View file

@ -150,7 +150,7 @@ fun aRoomMember(
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
role: RoomMember.Role = RoomMember.Role.User,
membershipChangeReason: String? = null,
) = RoomMember(
userId = userId,
@ -178,8 +178,8 @@ fun aRoomMemberList() = persistentListOf(
aWalter(),
)
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.ADMIN)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.MODERATOR)
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator)
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)

View file

@ -293,10 +293,12 @@ private fun RoomMemberListItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val roleText = when (roomMemberWithIdentity.roomMember.role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_member_list_role_administrator)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_member_list_role_moderator)
RoomMember.Role.USER -> null
val member = roomMemberWithIdentity.roomMember
val roleText = when (member.role) {
RoomMember.Role.Admin -> stringResource(R.string.screen_room_member_list_role_administrator)
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_member_list_role_moderator)
is RoomMember.Role.Owner -> stringResource(R.string.screen_room_member_list_role_owner)
else -> null
}
MatrixUserRow(

View file

@ -21,6 +21,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.model.roleOf
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onEach
@ -59,7 +60,8 @@ class RolesAndPermissionsNode @AssistedInject constructor(
lifecycleScope.launch {
room.roomInfoFlow
.filter { info ->
info.roomPowerLevels?.users?.get(room.sessionId) != RoomMember.Role.ADMIN.powerLevel
val role = info.roleOf(room.sessionId)
role != RoomMember.Role.Admin && role !is RoomMember.Role.Owner
}
.take(1)
.onEach { navigateUp() }

View file

@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.activeRoomMembers
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -50,14 +51,23 @@ class RolesAndPermissionsPresenter @Inject constructor(
}
val moderatorCount by remember {
derivedStateOf {
roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.MODERATOR)
roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Moderator)
}
}
val adminCount by remember {
derivedStateOf {
roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.ADMIN)
val admins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Admin)
val ownersCount = if (roomInfo.privilegedCreatorRole) {
val superAdmins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = false))
val creators = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = true))
superAdmins + creators
} else {
0
}
admins + ownersCount
}
}
val canDemoteSelf = remember { derivedStateOf { roomInfo.roleOf(room.sessionId) !is RoomMember.Role.Owner } }
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val resetPermissionsAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
@ -83,8 +93,10 @@ class RolesAndPermissionsPresenter @Inject constructor(
}
return RolesAndPermissionsState(
roomSupportsOwnerRole = roomInfo.privilegedCreatorRole,
adminCount = adminCount,
moderatorCount = moderatorCount,
canDemoteSelf = canDemoteSelf.value,
changeOwnRoleAction = changeOwnRoleAction.value,
resetPermissionsAction = resetPermissionsAction.value,
eventSink = { handleEvent(it) },
@ -110,8 +122,6 @@ class RolesAndPermissionsPresenter @Inject constructor(
}
private fun RoomInfo.userCountWithRole(userIds: List<UserId>, role: RoomMember.Role): Int {
return this.roomPowerLevels?.users?.count { (userId, level) ->
RoomMember.Role.forPowerLevel(level) == role && userId in userIds
} ?: 0
return usersWithRole(role).filter { it in userIds }.size
}
}

View file

@ -10,8 +10,10 @@ package io.element.android.features.roomdetails.impl.rolesandpermissions
import io.element.android.libraries.architecture.AsyncAction
data class RolesAndPermissionsState(
val roomSupportsOwnerRole: Boolean,
val adminCount: Int,
val moderatorCount: Int,
val canDemoteSelf: Boolean,
val changeOwnRoleAction: AsyncAction<Unit>,
val resetPermissionsAction: AsyncAction<Unit>,
val eventSink: (RolesAndPermissionsEvents) -> Unit,

View file

@ -13,7 +13,7 @@ import io.element.android.libraries.architecture.AsyncAction
class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermissionsState> {
override val values: Sequence<RolesAndPermissionsState>
get() = sequenceOf(
aRolesAndPermissionsState(),
aRolesAndPermissionsState(roomSupportsOwners = false),
aRolesAndPermissionsState(adminCount = 1, moderatorCount = 2),
aRolesAndPermissionsState(
adminCount = 1,
@ -45,17 +45,22 @@ class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermis
moderatorCount = 2,
resetPermissionsAction = AsyncAction.Failure(IllegalStateException("Failed to reset permissions")),
),
aRolesAndPermissionsState(canDemoteSelf = false),
)
}
internal fun aRolesAndPermissionsState(
roomSupportsOwners: Boolean = true,
adminCount: Int = 0,
moderatorCount: Int = 0,
canDemoteSelf: Boolean = true,
changeOwnRoleAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
resetPermissionsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
) = RolesAndPermissionsState(
roomSupportsOwnerRole = roomSupportsOwners,
adminCount = adminCount,
canDemoteSelf = canDemoteSelf,
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction,
resetPermissionsAction = resetPermissionsAction,

View file

@ -55,8 +55,14 @@ fun RolesAndPermissionsView(
onBackClick = rolesAndPermissionsNavigator::onBackClick,
) {
ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_roles_header), hasDivider = false)
val adminsTitle = if (state.roomSupportsOwnerRole) {
stringResource(R.string.screen_room_roles_and_permissions_admins_and_owners)
} else {
stringResource(R.string.screen_room_roles_and_permissions_admins)
}
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_admins)) },
headlineContent = { Text(adminsTitle) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
trailingContent = ListItemContent.Text("${state.adminCount}"),
onClick = { rolesAndPermissionsNavigator.openAdminList() },
@ -67,11 +73,13 @@ fun RolesAndPermissionsView(
trailingContent = ListItemContent.Text("${state.moderatorCount}"),
onClick = { rolesAndPermissionsNavigator.openModeratorList() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit()))
)
if (state.canDemoteSelf) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit()))
)
}
ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_permissions_header), hasDivider = true)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_room_details)) },
@ -170,7 +178,7 @@ private fun ChangeOwnRoleBottomSheet(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
}
},
style = ListItemStyle.Destructive,
@ -179,7 +187,7 @@ private fun ChangeOwnRoleBottomSheet(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.USER))
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User))
}
},
style = ListItemStyle.Destructive,

View file

@ -44,8 +44,8 @@ class ChangeRolesNode @AssistedInject constructor(
private val presenter = presenterFactory.run {
val role = when (inputs.listType) {
is ListType.Admins -> RoomMember.Role.ADMIN
is ListType.Moderators -> RoomMember.Role.MODERATOR
is ListType.Admins -> RoomMember.Role.Admin
is ListType.Moderators -> RoomMember.Role.Moderator
}
create(role)
}

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
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.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
@ -74,18 +75,17 @@ class ChangeRolesPresenter @AssistedInject constructor(
val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val usersWithRole = produceState(initialValue = persistentListOf()) {
room.usersWithRole(role)
.map { members -> members.map { it.toMatrixUser() } }
.onEach { users ->
val previous: PersistentList<MatrixUser> = value
value = users.toPersistentList()
// Users who were selected but didn't have the role, so their role change was pending
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
// Users who no longer have the role
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } }
.onEach { users ->
val previous: PersistentList<MatrixUser> = value
value = users.toPersistentList()
// Users who were selected but didn't have the role, so their role change was pending
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
// Users who no longer have the role
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
}
val roomMemberState by room.membersStateFlow.collectAsState()
@ -96,7 +96,6 @@ class ChangeRolesPresenter @AssistedInject constructor(
.search(query.orEmpty())
.groupedByRole()
println(results)
searchResults = if (results.isEmpty()) {
SearchBarResultState.NoResultsFound()
} else {
@ -108,9 +107,10 @@ class ChangeRolesPresenter @AssistedInject constructor(
val roomInfo by room.roomInfoFlow.collectAsState()
fun canChangeMemberRole(userId: UserId): Boolean {
// An admin can't remove or demote another admin
val powerLevel = roomInfo.roomPowerLevels?.users?.get(userId) ?: 0L
return RoomMember.Role.forPowerLevel(powerLevel) != RoomMember.Role.ADMIN
// This is used to group the
val currentUserRole = roomInfo.roleOf(room.sessionId)
val otherUserRole = roomInfo.roleOf(userId)
return currentUserRole.powerLevel > otherUserRole.powerLevel
}
fun handleEvent(event: ChangeRolesEvent) {
@ -132,11 +132,21 @@ class ChangeRolesPresenter @AssistedInject constructor(
selectedUsers.value = newList.toImmutableList()
}
is ChangeRolesEvent.Save -> {
if (role == RoomMember.Role.ADMIN && selectedUsers != usersWithRole && !saveState.value.isConfirming()) {
// Confirm adding admin
saveState.value = AsyncAction.ConfirmingNoParams
} else if (!saveState.value.isLoading()) {
coroutineScope.save(usersWithRole.value, selectedUsers, saveState)
val currentUserIsAdmin = roomInfo.roleOf(room.sessionId) == RoomMember.Role.Admin
val isModifyingAdmins = role == RoomMember.Role.Admin
val hasChanges = selectedUsers != usersWithRole
val isConfirming = saveState.value.isConfirming()
val needsConfirmation = currentUserIsAdmin && isModifyingAdmins && hasChanges && !isConfirming
when {
needsConfirmation -> {
// Confirm modifying users
saveState.value = AsyncAction.ConfirmingNoParams
}
!saveState.value.isLoading() -> {
coroutineScope.save(usersWithRole.value, selectedUsers, saveState)
}
}
}
is ChangeRolesEvent.ClearError -> {
@ -174,10 +184,12 @@ class ChangeRolesPresenter @AssistedInject constructor(
}
private fun List<RoomMember>.groupedByRole(): MembersByRole {
val groupedMembers = MembersByRole(this)
return MembersByRole(
admins = filter { it.role == RoomMember.Role.ADMIN }.sorted(),
moderators = filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
members = filter { it.role == RoomMember.Role.USER }.sorted(),
owners = groupedMembers.owners.sorted(),
admins = groupedMembers.admins.sorted(),
moderators = groupedMembers.moderators.sorted(),
members = groupedMembers.members.sorted(),
)
}
@ -202,7 +214,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
}
for (selectedUser in toRemove) {
analyticsService.capture(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
add(UserRoleChange(selectedUser.userId, RoomMember.Role.USER))
add(UserRoleChange(selectedUser.userId, RoomMember.Role.User))
}
}

View file

@ -30,17 +30,19 @@ data class ChangeRolesState(
)
data class MembersByRole(
val owners: ImmutableList<RoomMember>,
val admins: ImmutableList<RoomMember>,
val moderators: ImmutableList<RoomMember>,
val members: ImmutableList<RoomMember>,
) {
constructor(members: List<RoomMember>) : this(
admins = members.filter { it.role == RoomMember.Role.ADMIN }.sorted(),
moderators = members.filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
members = members.filter { it.role == RoomMember.Role.USER }.sorted(),
owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(),
admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(),
moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(),
members = members.filter { it.role == RoomMember.Role.User }.sorted(),
)
fun isEmpty() = admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
}
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {

View file

@ -8,6 +8,7 @@
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
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
@ -15,6 +16,7 @@ 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.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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -24,7 +26,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
override val values: Sequence<ChangeRolesState>
get() = sequenceOf(
aChangeRolesState(),
aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.MODERATOR),
aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.Moderator),
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
aChangeRolesStateWithSelectedUsers(),
aChangeRolesStateWithSelectedUsers().copy(
@ -41,11 +43,12 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
aChangeRolesStateWithOwners(),
)
}
internal fun aChangeRolesState(
role: RoomMember.Role = RoomMember.Role.ADMIN,
role: RoomMember.Role = RoomMember.Role.Admin,
query: String? = null,
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
@ -84,3 +87,47 @@ internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
hasPendingChanges = true,
canRemoveMember = { it != UserId("@alice:server.org") },
)
internal fun aChangeRolesStateWithOwners() = aChangeRolesState(
role = RoomMember.Role.Admin,
searchResults = SearchBarResultState.Results(
MembersByRole(
members = persistentListOf(
aRoomMember(
userId = UserId("@alice:server.org"),
displayName = "Alice",
role = RoomMember.Role.Owner(isCreator = true),
),
aRoomMember(
userId = UserId("@bob:server.org"),
displayName = "Bob",
role = RoomMember.Role.Owner(isCreator = false),
),
aRoomMember(
userId = UserId("@carol:server.org"),
displayName = "Carol",
role = RoomMember.Role.Admin,
),
aRoomMember(
userId = UserId("@david:server.org"),
displayName = "David",
role = RoomMember.Role.User,
),
)
),
),
canRemoveMember = { userId ->
when (userId) {
UserId("@alice:server.org") -> false // Owner - creator
UserId("@bob:server.org") -> false // Owner - super admin
UserId("@carol:server.org") -> true // Admin
UserId("@david:server.org") -> true // User
else -> false
}
},
selectedUsers = persistentListOf(
aMatrixUser(id = "@alice:server.org", displayName = "Alice"),
aMatrixUser(id = "@bob:server.org", displayName = "Bob"),
aMatrixUser(id = "@carol:server.org", displayName = "Carol"),
)
)

View file

@ -57,6 +57,7 @@ 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.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@ -96,9 +97,9 @@ fun ChangeRolesView(
AnimatedVisibility(visible = !state.isSearchActive) {
TopAppBar(
titleStr = when (state.role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_change_role_administrators_title)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_change_role_moderators_title)
RoomMember.Role.USER -> error("This should never be reached")
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")
},
navigationIcon = {
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
@ -187,7 +188,7 @@ fun ChangeRolesView(
when (state.savingState) {
is AsyncAction.Confirming -> {
if (state.role == RoomMember.Role.ADMIN) {
if (state.role == RoomMember.Role.Admin) {
// Confirm adding new admins dialogs
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
@ -234,10 +235,30 @@ private fun SearchResultsList(
item {
selectedUsersList(selectedUsers)
}
if (searchResults.owners.isNotEmpty()) {
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_owners)) }
item {
Text(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
text = stringResource(R.string.screen_room_change_role_moderators_owner_section_footer),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
)
}
items(searchResults.owners, key = { it.userId }) { roomMember ->
ListMemberItem(
roomMember = roomMember,
canRemoveMember = canRemoveMember,
onToggleSelection = onToggleSelection,
selectedUsers = selectedUsers
)
}
}
if (searchResults.admins.isNotEmpty()) {
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) }
// Add a footer for the admin section in change role to moderator screen
if (currentRole == RoomMember.Role.MODERATOR) {
if (currentRole == RoomMember.Role.Moderator) {
item {
Text(
modifier = Modifier
@ -303,20 +324,24 @@ private fun ListMemberItem(
) {
val canToggle = canRemoveMember(roomMember.userId)
val trailingContent: @Composable (() -> Unit) = {
Checkbox(
checked = selectedUsers.any { it.userId == roomMember.userId },
onCheckedChange = { onToggleSelection(roomMember) },
enabled = canToggle,
)
if (canToggle) {
Checkbox(
checked = selectedUsers.any { it.userId == roomMember.userId },
onCheckedChange = { onToggleSelection(roomMember) },
)
}
}
Column {
MemberRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }),
avatarData = roomMember.getAvatarData(size = AvatarSize.UserListItem),
name = roomMember.getBestName(),
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
isPending = roomMember.membership == RoomMembershipState.INVITE,
trailingContent = trailingContent,
)
HorizontalDivider()
}
MemberRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }),
avatarData = roomMember.getAvatarData(size = AvatarSize.UserListItem),
name = roomMember.getBestName(),
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
isPending = roomMember.membership == RoomMembershipState.INVITE,
trailingContent = trailingContent,
)
}
@Composable

View file

@ -55,15 +55,15 @@ internal fun aChangeRoomPermissionsState(
private fun previewPermissions(): RoomPowerLevelsValues {
return RoomPowerLevelsValues(
// MembershipModeration section
invite = RoomMember.Role.ADMIN.powerLevel,
kick = RoomMember.Role.MODERATOR.powerLevel,
ban = RoomMember.Role.USER.powerLevel,
invite = RoomMember.Role.Admin.powerLevel,
kick = RoomMember.Role.Moderator.powerLevel,
ban = RoomMember.Role.User.powerLevel,
// MessagesAndContent section
redactEvents = RoomMember.Role.MODERATOR.powerLevel,
sendEvents = RoomMember.Role.ADMIN.powerLevel,
redactEvents = RoomMember.Role.Moderator.powerLevel,
sendEvents = RoomMember.Role.Admin.powerLevel,
// RoomDetails section
roomName = RoomMember.Role.ADMIN.powerLevel,
roomAvatar = RoomMember.Role.MODERATOR.powerLevel,
roomTopic = RoomMember.Role.USER.powerLevel,
roomName = RoomMember.Role.Admin.powerLevel,
roomAvatar = RoomMember.Role.Moderator.powerLevel,
roomTopic = RoomMember.Role.User.powerLevel,
)
}

View file

@ -80,21 +80,21 @@ fun ChangeRoomPermissionsView(
ListSectionHeader(titleForSection(item = permissionItem), hasDivider = index > 0)
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.ADMIN,
role = RoomMember.Role.Admin,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
}
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.MODERATOR,
role = RoomMember.Role.Moderator,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
}
SelectRoleItem(
permissionsItem = permissionItem,
role = RoomMember.Role.USER,
role = RoomMember.Role.User,
currentPermissions = state.currentPermissions
) { item, role ->
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(item, role))
@ -135,9 +135,10 @@ private fun SelectRoleItem(
onClick: (RoomPermissionType, RoomMember.Role) -> Unit
) {
val title = when (role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_change_permissions_administrators)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_change_permissions_moderators)
RoomMember.Role.USER -> stringResource(R.string.screen_room_change_permissions_everyone)
RoomMember.Role.Admin -> stringResource(R.string.screen_room_change_permissions_administrators)
RoomMember.Role.Moderator -> stringResource(R.string.screen_room_change_permissions_moderators)
RoomMember.Role.User -> stringResource(R.string.screen_room_change_permissions_everyone)
else -> error("Unsupported role selected: $role")
}
ListItem(
headlineContent = { Text(text = title) },

View file

@ -127,7 +127,7 @@ Wir empfehlen nicht, die Verschlüsselung für Chatrooms die jeder finden und be
<string name="screen_security_and_privacy_room_access_invite_only_option_description">"Personen können nur beitreten, wenn sie eingeladen werden."</string>
<string name="screen_security_and_privacy_room_access_invite_only_option_title">"Nur auf Einladung"</string>
<string name="screen_security_and_privacy_room_access_section_header">"Chatroomzugang"</string>
<string name="screen_security_and_privacy_room_access_space_members_option_description">"Räume werden zur Zeit nicht unterstützt."</string>
<string name="screen_security_and_privacy_room_access_space_members_option_description">"Spaces werden zur Zeit nicht unterstützt."</string>
<string name="screen_security_and_privacy_room_access_space_members_option_title">"Spacemitglieder"</string>
<string name="screen_security_and_privacy_room_address_section_footer">"Um den Chatroom im Chatroomverzeichnis sichtbar zu machen, benötigen Sie eine Chatroomadresse."</string>
<string name="screen_security_and_privacy_room_address_section_header">"Chatroomadresse"</string>

View file

@ -9,6 +9,7 @@
<string name="screen_room_details_encryption_enabled_subtitle">"Žinutės yra užrakintos. Tik Jūs ir gavėjai turite unikalius raktus joms atrakinti."</string>
<string name="screen_room_details_encryption_enabled_title">"Įjungtas žinučių šifravimas"</string>
<string name="screen_room_details_invite_people_title">"Pakviesti žmonių"</string>
<string name="screen_room_details_leave_conversation_title">"Palikti pokalbį"</string>
<string name="screen_room_details_leave_room_title">"Palikti kambarį"</string>
<string name="screen_room_details_room_name_label">"Kambario pavadinimas"</string>
<string name="screen_room_details_security_title">"Saugumas"</string>

View file

@ -82,6 +82,7 @@
<string name="screen_room_member_list_pending_header_title">"Čaká sa"</string>
<string name="screen_room_member_list_role_administrator">"Administrátor"</string>
<string name="screen_room_member_list_role_moderator">"Moderátor"</string>
<string name="screen_room_member_list_role_owner">"Vlastník"</string>
<string name="screen_room_member_list_room_members_header_title">"Členovia miestnosti"</string>
<string name="screen_room_member_list_unbanning_user">"Zrušenie zákazu %1$s"</string>
<string name="screen_room_notification_settings_allow_custom">"Povoliť vlastné nastavenie"</string>

View file

@ -28,6 +28,7 @@
<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_section_administrators">"Admins"</string>
<string name="screen_room_change_role_section_moderators">"Moderators"</string>
@ -81,6 +82,7 @@
<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_notification_settings_allow_custom">"Allow custom setting"</string>
@ -98,12 +100,14 @@
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</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>

View file

@ -66,7 +66,7 @@ class RolesAndPermissionPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
runCurrent()
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Loading)
@ -87,7 +87,7 @@ class RolesAndPermissionPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
runCurrent()
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Loading)

View file

@ -47,12 +47,30 @@ class RolesAndPermissionsViewTest {
fun `tapping on Admins opens admin list`() {
ensureCalledOnce { callback ->
rule.setRolesAndPermissionsView(
aRolesAndPermissionsState(
roomSupportsOwners = false,
eventSink = EventsRecorder(expectEvents = false)
),
openAdminList = callback,
)
rule.clickOn(R.string.screen_room_roles_and_permissions_admins)
}
}
@Test
fun `tapping on Admins and Owners opens admin list`() {
ensureCalledOnce { callback ->
rule.setRolesAndPermissionsView(
aRolesAndPermissionsState(
roomSupportsOwners = true,
eventSink = EventsRecorder(expectEvents = false)
),
openAdminList = callback,
)
rule.clickOn(R.string.screen_room_roles_and_permissions_admins_and_owners)
}
}
@Test
fun `tapping on Moderators opens moderators list`() {
ensureCalledOnce { callback ->
@ -126,7 +144,7 @@ class RolesAndPermissionsViewTest {
)
rule.clickOn(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)
rule.mainClock.advanceTimeBy(1_000L)
recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
}
@Test
@ -140,7 +158,7 @@ class RolesAndPermissionsViewTest {
)
rule.clickOn(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)
rule.mainClock.advanceTimeBy(1_000L)
recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.USER))
recorder.assertSingle(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User))
}
@Test
@ -160,6 +178,7 @@ class RolesAndPermissionsViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRolesAndPermissionsView(
state: RolesAndPermissionsState = aRolesAndPermissionsState(
roomSupportsOwners = false,
eventSink = EventsRecorder(expectEvents = false),
),
goBack: () -> Unit = EnsureNeverCalled(),

View file

@ -12,6 +12,7 @@ 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
@ -23,6 +24,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
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
@ -43,7 +45,7 @@ class ChangeRolesPresenterTest {
presenter.present()
}.test {
with(awaitItem()) {
assertThat(role).isEqualTo(RoomMember.Role.ADMIN)
assertThat(role).isEqualTo(RoomMember.Role.Admin)
assertThat(query).isNull()
assertThat(isSearchActive).isFalse()
assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
@ -70,6 +72,76 @@ class ChangeRolesPresenterTest {
}
}
@Test
fun `present - canChangeRole of users with lower power level unless they are owners`() = runTest {
val creatorUserId = UserId("@creator:matrix.org")
val superAdminUserId = UserId("@super_admin:matrix.org")
val room = FakeJoinedRoom().apply {
// User is a creator, so they can change roles of other members. So is `creatorUserId`.
givenRoomInfo(
aRoomInfo(
roomCreators = listOf(sessionId, creatorUserId),
roomPowerLevels = RoomPowerLevels(
defaultRoomPowerLevelValues(),
users = persistentMapOf(
// bob is Admin
A_USER_ID_2 to RoomMember.Role.Admin.powerLevel,
// carol is Moderator
A_USER_ID_3 to RoomMember.Role.Moderator.powerLevel,
// super_admin is Owner - Superadmin
superAdminUserId to RoomMember.Role.Owner(isCreator = false).powerLevel,
)
)
)
)
val roomMemberList = aRoomMemberList() + listOf(
// Owner - superadmin
aRoomMember(userId = superAdminUserId, role = RoomMember.Role.Owner(isCreator = true)),
// Owner - creator
aRoomMember(userId = creatorUserId, role = RoomMember.Role.Owner(isCreator = true))
)
givenRoomMembersState(RoomMembersState.Ready(roomMemberList.toPersistentList()))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().run {
assertThat(canChangeMemberRole(A_USER_ID_2)).isTrue() // Admin
assertThat(canChangeMemberRole(A_USER_ID_3)).isTrue() // Moderator
assertThat(canChangeMemberRole(creatorUserId)).isFalse() // Owner
}
}
}
@Test
fun `present - when modifying admins, creators are displayed too`() = runTest {
val room = FakeJoinedRoom().apply {
val creatorUserId = UserId("@creator:matrix.org")
val memberList = aRoomMemberList()
.plus(aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = creatorUserId))
.toPersistentList()
givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId)))
givenRoomMembersState(RoomMembersState.Ready(memberList))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().searchResults.run {
assertThat(this).isInstanceOf(SearchBarResultState.Results::class.java)
val results = (this as SearchBarResultState.Results).results
assertThat(results.admins).isNotEmpty()
assertThat(results.owners).isNotEmpty()
assertThat(results.owners.last().role).isEqualTo(RoomMember.Role.Owner(isCreator = true))
}
}
}
@Test
fun `present - ToggleSearchActive changes the value`() = runTest {
val room = FakeJoinedRoom().apply {
@ -145,7 +217,7 @@ class ChangeRolesPresenterTest {
fun `present - UserSelectionToggle adds and removes users from the selected user list`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@ -167,7 +239,7 @@ class ChangeRolesPresenterTest {
fun `present - hasPendingChanges is true when the initial selected users don't match the new ones`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@ -196,7 +268,7 @@ class ChangeRolesPresenterTest {
fun `present - Exit will display success if no pending changes`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@ -216,7 +288,7 @@ class ChangeRolesPresenterTest {
fun `present - CancelExit will remove exit confirmation`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@ -242,7 +314,7 @@ class ChangeRolesPresenterTest {
fun `present - Exit will display a confirmation dialog if there are pending changes, calling it again will actually exit`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
@ -273,9 +345,9 @@ class ChangeRolesPresenterTest {
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.ADMIN, room = room)
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -302,9 +374,9 @@ class ChangeRolesPresenterTest {
fun `present - CancelSave will remove the confirmation dialog`() = runTest {
val room = FakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.ADMIN)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.ADMIN, room = room)
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -331,10 +403,10 @@ class ChangeRolesPresenterTest {
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.MODERATOR)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Moderator)))
}
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.MODERATOR,
role = RoomMember.Role.Moderator,
room = room,
analyticsService = analyticsService
)
@ -358,15 +430,55 @@ class ChangeRolesPresenterTest {
}
}
@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()
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.success(Unit) },
baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }),
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(
aRoomInfo(
roomCreators = listOf(sessionId),
roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Admin, userId = A_USER_ID_2)
)
)
}
val presenter = createChangeRolesPresenter(
role = RoomMember.Role.Admin,
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)
val loadingState = awaitItem()
assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java)
skipItems(1)
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User))
}
}
@Test
fun `present - Save can handle failures and ClearError clears them`() = runTest {
val room = FakeJoinedRoom(
updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) }
).apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.MODERATOR)))
givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Moderator, userId = A_USER_ID)))
}
val presenter = createChangeRolesPresenter(role = RoomMember.Role.MODERATOR, room = room)
val presenter = createChangeRolesPresenter(role = RoomMember.Role.Moderator, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -399,7 +511,7 @@ class ChangeRolesPresenterTest {
}
private fun TestScope.createChangeRolesPresenter(
role: RoomMember.Role = RoomMember.Role.ADMIN,
role: RoomMember.Role = RoomMember.Role.Admin,
room: FakeJoinedRoom = FakeJoinedRoom(),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),

View file

@ -41,11 +41,25 @@ class ChangeRolesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `passing a 'USER' role throws an exception`() {
fun `passing a 'User' role throws an exception`() {
val exception = runCatchingExceptions {
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.USER,
role = RoomMember.Role.User,
eventSink = EnsureNeverCalledWithParam(),
),
)
}.exceptionOrNull()
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(),
),
)
@ -166,7 +180,7 @@ class ChangeRolesViewTest {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.ADMIN,
role = RoomMember.Role.Admin,
isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,
@ -183,7 +197,7 @@ class ChangeRolesViewTest {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.ADMIN,
role = RoomMember.Role.Admin,
isSearchActive = true,
savingState = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,

View file

@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5
import io.element.android.libraries.matrix.test.A_USER_ID_6
import io.element.android.libraries.matrix.test.A_USER_ID_7
import io.element.android.libraries.matrix.test.room.aRoomMember
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
@ -22,22 +24,28 @@ class MembersByRoleTest {
@Test
fun `constructor - with single member list categorizes and sorts members`() {
val members = listOf(
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.Admin),
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.Admin),
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)),
aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)),
)
val membersByRole = MembersByRole(members = members)
assertThat(membersByRole.owners).containsExactly(
aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)),
aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)),
)
assertThat(membersByRole.admins).containsExactly(
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.Admin),
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.Admin),
)
assertThat(membersByRole.moderators).isEmpty()
assertThat(membersByRole.members).containsExactly(
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.User),
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.User),
)
}
@ -46,24 +54,35 @@ class MembersByRoleTest {
val emptyMembersByRole = MembersByRole(emptyList())
assertThat(emptyMembersByRole.isEmpty()).isTrue()
val membersByRoleWithOwners = MembersByRole(
owners = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)),
admins = persistentListOf(),
moderators = persistentListOf(),
members = persistentListOf(),
)
assertThat(membersByRoleWithOwners.isEmpty()).isFalse()
val membersByRoleWithAdmins = MembersByRole(
admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.ADMIN)),
owners = persistentListOf(),
admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)),
moderators = persistentListOf(),
members = persistentListOf(),
)
assertThat(membersByRoleWithAdmins.isEmpty()).isFalse()
val membersByRoleWithModerators = MembersByRole(
owners = persistentListOf(),
admins = persistentListOf(),
moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.MODERATOR)),
moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Moderator)),
members = persistentListOf(),
)
assertThat(membersByRoleWithModerators.isEmpty()).isFalse()
val membersByRoleWithMembers = MembersByRole(
owners = persistentListOf(),
admins = persistentListOf(),
moderators = persistentListOf(),
members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.USER)),
members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.User)),
)
assertThat(membersByRoleWithMembers.isEmpty()).isFalse()
}

View file

@ -15,9 +15,9 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.RoomModeration
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember.Role.ADMIN
import io.element.android.libraries.matrix.api.room.RoomMember.Role.MODERATOR
import io.element.android.libraries.matrix.api.room.RoomMember.Role.USER
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator
import io.element.android.libraries.matrix.api.room.RoomMember.Role.User
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -100,13 +100,13 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
assertThat(state.currentPermissions?.roomName).isEqualTo(ADMIN.powerLevel)
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(hasChanges).isTrue()
}
}
@ -120,28 +120,28 @@ class ChangeBaseRoomPermissionsPresenterTest {
}.test {
val state = awaitUpdatedItem()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, Moderator))
val items = cancelAndConsumeRemainingEvents()
(items.last() as? Event.Item<ChangeRoomPermissionsState>)?.value?.run {
assertThat(currentPermissions).isEqualTo(
RoomPowerLevelsValues(
invite = MODERATOR.powerLevel,
kick = MODERATOR.powerLevel,
ban = MODERATOR.powerLevel,
redactEvents = MODERATOR.powerLevel,
sendEvents = MODERATOR.powerLevel,
roomName = MODERATOR.powerLevel,
roomAvatar = MODERATOR.powerLevel,
roomTopic = MODERATOR.powerLevel,
invite = Moderator.powerLevel,
kick = Moderator.powerLevel,
ban = Moderator.powerLevel,
redactEvents = Moderator.powerLevel,
sendEvents = Moderator.powerLevel,
roomName = Moderator.powerLevel,
roomAvatar = Moderator.powerLevel,
roomTopic = Moderator.powerLevel,
)
)
}
@ -162,17 +162,17 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
assertThat(state.currentPermissions?.roomName).isEqualTo(ADMIN.powerLevel)
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, USER))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, ADMIN))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, ADMIN))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, ADMIN))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, Moderator))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, User))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, Admin))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, Admin))
skipItems(7)
assertThat(awaitItem().hasChanges).isTrue()
@ -181,7 +181,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading)
assertThat(awaitItem().hasChanges).isFalse()
awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
assertThat(analyticsService.capturedEvents).containsExactlyElementsIn(
@ -227,17 +227,17 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
assertThat(state.currentPermissions?.roomName).isEqualTo(ADMIN.powerLevel)
assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel)
assertThat(state.hasChanges).isFalse()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Save)
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading)
awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
// Couldn't save the changes, so they're still pending
assertThat(hasChanges).isTrue()
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java)
@ -245,7 +245,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions)
awaitItem().run {
assertThat(currentPermissions?.roomName).isEqualTo(MODERATOR.powerLevel)
assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel)
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(hasChanges).isTrue()
}
@ -259,7 +259,7 @@ class ChangeBaseRoomPermissionsPresenterTest {
presenter.present()
}.test {
val state = awaitUpdatedItem()
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, MODERATOR))
state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, Moderator))
assertThat(awaitItem().hasChanges).isTrue()
state.eventSink(ChangeRoomPermissionsEvent.Exit)

View file

@ -115,9 +115,9 @@ class ChangeBaseRoomPermissionsViewTest {
rule.onAllNodesWithText(users).onFirst().performClick()
recorder.assertList(
listOf(
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.ADMIN),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.MODERATOR),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.USER),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.Admin),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.Moderator),
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, RoomMember.Role.User),
)
)
}

View file

@ -30,6 +30,9 @@ data class RoomDescription(
enum class JoinRule {
PUBLIC,
KNOCK,
RESTRICTED,
KNOCK_RESTRICTED,
INVITE,
UNKNOWN
}

View file

@ -21,6 +21,9 @@ fun MatrixRoomDescription.toFeatureModel(): RoomDescription {
joinRule = when (joinRule) {
MatrixRoomDescription.JoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC
MatrixRoomDescription.JoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK
MatrixRoomDescription.JoinRule.RESTRICTED -> RoomDescription.JoinRule.RESTRICTED
MatrixRoomDescription.JoinRule.KNOCK_RESTRICTED -> RoomDescription.JoinRule.KNOCK_RESTRICTED
MatrixRoomDescription.JoinRule.INVITE -> RoomDescription.JoinRule.INVITE
MatrixRoomDescription.JoinRule.UNKNOWN -> RoomDescription.JoinRule.UNKNOWN
}
)

View file

@ -12,4 +12,9 @@
<string name="screen_bottom_sheet_manage_room_member_remove">"Usuń z pokoju"</string>
<string name="screen_bottom_sheet_manage_room_member_remove_confirmation_title">"Usunąć członka i zablokować możliwość dołączenia w przyszłości?"</string>
<string name="screen_bottom_sheet_manage_room_member_removing_user">"Usuwanie %1$s…"</string>
<string name="screen_bottom_sheet_manage_room_member_unban">"Odbanuj z pokoju"</string>
<string name="screen_bottom_sheet_manage_room_member_unban_member_confirmation_action">"Odbanuj"</string>
<string name="screen_bottom_sheet_manage_room_member_unban_member_confirmation_description">"Mogą ponownie dołączyć do pokoju, po otrzymaniu zaproszenia"</string>
<string name="screen_bottom_sheet_manage_room_member_unban_member_confirmation_title">"Czy na pewno chcesz odbanować tego członka?"</string>
<string name="screen_bottom_sheet_manage_room_member_unbanning_user">"Odbanowuję %1$s"</string>
</resources>

View file

@ -61,8 +61,8 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = false,
canKick = false,
myUserRole = RoomMember.Role.USER,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.USER.powerLevel)
myUserRole = RoomMember.Role.User,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.User.powerLevel)
)
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@ -81,7 +81,7 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = true,
canKick = true,
myUserRole = RoomMember.Role.ADMIN,
myUserRole = RoomMember.Role.Admin,
targetRoomMember = null
)
createRoomMemberModerationPresenter(room = room).test {
@ -103,8 +103,8 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = true,
canKick = true,
myUserRole = RoomMember.Role.ADMIN,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.USER.powerLevel)
myUserRole = RoomMember.Role.Admin,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.User.powerLevel)
)
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@ -125,8 +125,8 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = true,
canKick = true,
myUserRole = RoomMember.Role.MODERATOR,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.ADMIN.powerLevel)
myUserRole = RoomMember.Role.Moderator,
targetRoomMember = aRoomMember(userId = A_USER_ID, powerLevel = RoomMember.Role.Admin.powerLevel)
)
createRoomMemberModerationPresenter(room = room).test {
val initialState = awaitState()
@ -147,7 +147,7 @@ class RoomMemberModerationPresenterTest {
val room = aJoinedRoom(
canBan = true,
canKick = true,
myUserRole = RoomMember.Role.MODERATOR,
myUserRole = RoomMember.Role.Moderator,
targetRoomMember = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.BAN)
)
createRoomMemberModerationPresenter(room = room).test {
@ -321,7 +321,7 @@ class RoomMemberModerationPresenterTest {
private fun aJoinedRoom(
canKick: Boolean = false,
canBan: Boolean = false,
myUserRole: RoomMember.Role = RoomMember.Role.USER,
myUserRole: RoomMember.Role = RoomMember.Role.User,
kickUserResult: Result<Unit> = Result.success(Unit),
banUserResult: Result<Unit> = Result.success(Unit),
unBanUserResult: Result<Unit> = Result.success(Unit),

View file

@ -60,6 +60,7 @@ class SecureBackupSetupPresenter @AssistedInject constructor(
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
SecureBackupSetupEvents.DismissDialog -> {
showSaveConfirmationDialog = false
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError)
}
SecureBackupSetupEvents.Done -> {
showSaveConfirmationDialog = true
@ -89,6 +90,7 @@ class SecureBackupSetupPresenter @AssistedInject constructor(
SecureBackupSetupStateMachine.State.CreatingKey -> SetupState.Creating
is SecureBackupSetupStateMachine.State.KeyCreated -> SetupState.Created(formattedRecoveryKey = key)
is SecureBackupSetupStateMachine.State.KeyCreatedAndSaved -> SetupState.CreatedAndSaved(formattedRecoveryKey = key)
is SecureBackupSetupStateMachine.State.Error -> SetupState.Error(exception)
}
}
@ -103,13 +105,20 @@ class SecureBackupSetupPresenter @AssistedInject constructor(
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it))
},
onFailure = {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
if (it is Exception) {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
}
}
)
} else {
observeEncryptionService(stateAndDispatch)
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()")
encryptionService.enableRecovery(waitForBackupsToUpload = false)
encryptionService.enableRecovery(waitForBackupsToUpload = false).onFailure {
Timber.tag(loggerTagSetup.value).e(it, "Failed to enable recovery")
if (it is Exception) {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
}
}
}
}

View file

@ -23,6 +23,7 @@ sealed interface SetupState {
data object Creating : SetupState
data class Created(val formattedRecoveryKey: String) : SetupState
data class CreatedAndSaved(val formattedRecoveryKey: String) : SetupState
data class Error(val exception: Exception) : SetupState
}
fun SetupState.recoveryKey(): String? = when (this) {

View file

@ -26,8 +26,8 @@ class SecureBackupSetupStateMachine @Inject constructor() : FlowReduxStateMachin
}
}
inState<State.CreatingKey> {
on { _: Event.SdkError, state: MachineState<State.CreatingKey> ->
state.override { State.Initial }
on { event: Event.SdkError, state: MachineState<State.CreatingKey> ->
state.override { State.Error(event.exception) }
}
on { event: Event.SdkHasCreatedKey, state: MachineState<State.CreatingKey> ->
state.override { State.KeyCreated(event.key) }
@ -38,6 +38,11 @@ class SecureBackupSetupStateMachine @Inject constructor() : FlowReduxStateMachin
state.override { State.KeyCreatedAndSaved(state.snapshot.key) }
}
}
inState<State.Error> {
on { _: Event.ClearError, state: MachineState<State.Error> ->
state.override { State.Initial }
}
}
inState<State.KeyCreatedAndSaved> {
}
}
@ -48,12 +53,14 @@ class SecureBackupSetupStateMachine @Inject constructor() : FlowReduxStateMachin
data object CreatingKey : State
data class KeyCreated(val key: String) : State
data class KeyCreatedAndSaved(val key: String) : State
data class Error(val exception: Exception) : State
}
sealed interface Event {
data object UserCreatesKey : Event
data class SdkHasCreatedKey(val key: String) : Event
data class SdkError(val throwable: Throwable) : Event
data class SdkError(val exception: Exception) : Event
data object UserSavedKey : Event
data object ClearError : Event
}
}

View file

@ -23,6 +23,7 @@ open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBacku
setupState = SetupState.CreatedAndSaved(aFormattedRecoveryKey()),
showSaveConfirmationDialog = true,
),
aSecureBackupSetupState(setupState = SetupState.Error(Exception("Test error"))),
// Add other states here
)
}

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