Merge remote-tracking branch 'origin/develop' into rav/app_startup_hook

This commit is contained in:
Richard van der Hoff
2026-08-27 23:06:37 +01:00
622 changed files with 5598 additions and 2575 deletions
-38
View File
@@ -63,44 +63,6 @@ jobs:
path: | path: |
app/build/outputs/bundle/gplayRelease/app-gplay-release.aab app/build/outputs/bundle/gplayRelease/app-gplay-release.aab
enterprise:
name: Create App Bundle Enterprise
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
concurrency:
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
with:
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create Enterprise app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: elementx-enterprise-app-gplay-bundle-unsigned
path: |
app/build/outputs/bundle/gplayRelease/app-gplay-release.aab
fdroid: fdroid:
name: Create APKs (FDroid) name: Create APKs (FDroid)
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2 -1
View File
@@ -50,9 +50,10 @@ PRs must meet these rules.
- Title = changelog entry — descriptive, no "Fixes #…". - Title = changelog entry — descriptive, no "Fixes #…".
- Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette). - Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette).
- Screenshots/videos for visual changes. - Screenshots/videos for visual changes.
- 500 additions max — split large changes. - 500 additions max into production code. Test code can be larger — split large changes.
- Commits need a title and description; no tiny or massive commits. - Commits need a title and description; no tiny or massive commits.
- No history rewrites. - No history rewrites.
- When creating a pull request and if changes contain new or update(s) of the Composable Previews, add the label "Record-Screenshots" to the PR so that the CI will record the screenshots.
--- ---
+84
View File
@@ -1,3 +1,87 @@
Changes in Element X v26.08.4
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.08.4 -->
## What's Changed
### 🧱 Build
* Add releaseV2.sh, a release script automating the GitHub release by @bmarty in https://github.com/element-hq/element-x-android/pull/7579
* Update submodule ref. by @bmarty in https://github.com/element-hq/element-x-android/pull/7582
### Others
* Keep the formatting of a caption that is being edited by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7572
* Say when the server cannot be reached instead of showing nothing by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7567
* Keep saving files to Downloads once 32 files share the same name by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7344
* Fix removed messages hiding its thread on deletion by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7480
* Drop the sender's name from the room list preview of two-person rooms by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7562
* Document how the Element Call base URL developer option is used by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7546
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.3...v26.08.4
Changes in Element X v26.08.3
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.08.3 -->
## What's Changed
### ✨ Features
* Let users enter their full Matrix user ID on the account provider screen by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7500
* Enable user status feature and hide status text in the timeline by @bmarty in https://github.com/element-hq/element-x-android/pull/7517
### 🐛 Bugfixes
* Fix cold start with a mobile deep link by @bmarty in https://github.com/element-hq/element-x-android/pull/7488
* Make the account provider screen scrollable so its description is not clipped by the keyboard by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7521
### 🧱 Build
* Update Gradle Wrapper from 9.7.0 to 9.7.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7485
* Fix flaky test in `DefaultVoiceMessageComposerPresenterTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7487
* Fix warning about deprecated Gradle `keepRules.file` API by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7498
* Update Enterprise Copyright in idea settings, so that new files get an expected header. by @bmarty in https://github.com/element-hq/element-x-android/pull/7501
* Remove workaround for Renovate. by @bmarty in https://github.com/element-hq/element-x-android/pull/7502
### 🚧 In development 🚧
* Make PIP react to camera orientation changes in the other participant's device by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6951
* Feature: global search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7410
### Dependency upgrades
* Update dependency org.maplibre.gl:android-sdk to v13.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7486
* Update dependency androidx.webkit:webkit to v1.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7484
* Update dependency io.sentry:sentry-android to v8.53.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7481
* Update dependency io.element.android:element-call-embedded to v0.24.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7474
* Update roborazzi to v1.72.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7489
* Update metro to v1.4.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7493
* Update dependency com.squareup.okhttp3:okhttp-bom to v5.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7512
* Update dependency com.google.guava:guava to v33.7.0-android by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7516
* Update sdk to version 26.08.25 by @bmarty in https://github.com/element-hq/element-x-android/pull/7550
### 💼 Element Pro
* Add `ClientEnterpriseHook` and `ClientBuilderEnterpriseHook` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7495
* Remove dependency on enterprise module. by @bmarty in https://github.com/element-hq/element-x-android/pull/7505
### Others
* Show the right image when opening one that has not finished sending by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7359
* Move strings to the login module by @bmarty in https://github.com/element-hq/element-x-android/pull/7494
* Allow empty problem description if the GitHub issue number is present for rageshakes by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7497
* Apply the system theme when switching back to it from a forced light or dark theme by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7453
* Show dialog with reason when user clicks on a message with a failure by @bmarty in https://github.com/element-hq/element-x-android/pull/7490
* Keep a focused timeline focused until the event has been rendered by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7463
* Do not render in-chat key verification requests in the timeline by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7443
* Share a file whose mime type is not one of the supported ones by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7435
* Show reactions in the pinned messages list by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6676
* Send a concrete mime type for media shared with a wildcard one by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7449
* Rename module and ensure that naming convention is followed by @bmarty in https://github.com/element-hq/element-x-android/pull/7514
* Fix lint issue, ensure that PiP methods are not called on old devices not supporting them. by @bmarty in https://github.com/element-hq/element-x-android/pull/7515
* Rename PreviewParameterProvider subclasses to the PreviewParam suffix by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7468
* Update Ref. by @bmarty in https://github.com/element-hq/element-x-android/pull/7518
* Remove the reference to the decommissioned OIDC playground by @sandhose in https://github.com/element-hq/element-x-android/pull/7519
* Hide the encryption badge when key storage is set up on the server by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7447
* Rename remaining events sealed interface by @bmarty in https://github.com/element-hq/element-x-android/pull/7533
* Stop waiting on a verification request the other session never accepts by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7457
* Point the proxy debugging doc at the method that builds the clients by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7543
* Show the build date in the screenshot gallery footer by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7544
* Run the database migrations again when a database is upgraded by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7570
* Update fonts on the headings by @bmarty in https://github.com/element-hq/element-x-android/pull/7520
* Add a "Push rules" entry in the developer options by @bmarty in https://github.com/element-hq/element-x-android/pull/7577
## New Contributors
* @sandhose made their first contribution in https://github.com/element-hq/element-x-android/pull/7519
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.2...v26.08.3
Changes in Element X v26.08.2 Changes in Element X v26.08.2
============================= =============================
@@ -8,11 +8,11 @@
package io.element.android.appnav.loggedin package io.element.android.appnav.loggedin
sealed interface LoggedInEvents { sealed interface LoggedInEvent {
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvent
data object CheckSlidingSyncProxyAvailability : LoggedInEvents data object CheckSlidingSyncProxyAvailability : LoggedInEvent
data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvents data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvent
data object DismissLocalNetworkPermissionPrompt : LoggedInEvents data object DismissLocalNetworkPermissionPrompt : LoggedInEvent
data object RequestLocationNetworkPermission : LoggedInEvents data object RequestLocationNetworkPermission : LoggedInEvent
} }
@@ -30,8 +30,6 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -67,7 +65,6 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor, private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter = private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -108,9 +105,18 @@ class LoggedInPresenter(
} }
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState() val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val isOnline by syncService.isOnline.collectAsState() val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember { val hasNetwork by networkMonitor.connectivity.collectAsState()
val syncIndicatorState by remember {
derivedStateOf { derivedStateOf {
isOnline && syncIndicator == RoomListService.SyncIndicator.Show when {
isOnline -> if (syncIndicator == RoomListService.SyncIndicator.Show) {
SyncIndicatorState.Syncing
} else {
SyncIndicatorState.Hidden
}
hasNetwork == NetworkStatus.Connected -> SyncIndicatorState.ServerUnreachable
else -> SyncIndicatorState.Hidden
}
} }
} }
var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) } var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) }
@@ -124,13 +130,9 @@ class LoggedInPresenter(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support. // Keep automatic call status (m.call) in sync with homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus) val enabled = matrixClient.isUserStatusSupported().getOrDefault(false)
.onEach { isUserStatusEnabled -> matrixClient.enableAutomaticCallStatus(enabled)
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
.launchIn(this)
} }
val networkConnectivity by networkMonitor.connectivity.collectAsState() val networkConnectivity by networkMonitor.connectivity.collectAsState()
@@ -156,9 +158,9 @@ class LoggedInPresenter(
else -> LocalNetworkPermissionDialog.Settings else -> LocalNetworkPermissionDialog.Settings
} }
fun handleEvent(event: LoggedInEvents) { fun handleEvent(event: LoggedInEvent) {
when (event) { when (event) {
is LoggedInEvents.CloseErrorDialog -> { is LoggedInEvent.CloseErrorDialog -> {
pusherRegistrationState.value = AsyncData.Uninitialized pusherRegistrationState.value = AsyncData.Uninitialized
if (event.doNotShowAgain) { if (event.doNotShowAgain) {
coroutineScope.launch { coroutineScope.launch {
@@ -166,17 +168,17 @@ class LoggedInPresenter(
} }
} }
} }
LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch { LoggedInEvent.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
forceNativeSlidingSyncMigration = matrixClient.needsForcedNativeSlidingSyncMigration().getOrDefault(false) forceNativeSlidingSyncMigration = matrixClient.needsForcedNativeSlidingSyncMigration().getOrDefault(false)
} }
LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch { LoggedInEvent.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
// Force the logout since Native Sliding Sync is already enforced by the SDK // Force the logout since Native Sliding Sync is already enforced by the SDK
matrixClient.logout(userInitiated = true, ignoreSdkError = true) matrixClient.logout(userInitiated = true, ignoreSdkError = true)
} }
LoggedInEvents.DismissLocalNetworkPermissionPrompt -> { LoggedInEvent.DismissLocalNetworkPermissionPrompt -> {
localNetworkPromptDismissedThisSession = true localNetworkPromptDismissedThisSession = true
} }
LoggedInEvents.RequestLocationNetworkPermission -> { LoggedInEvent.RequestLocationNetworkPermission -> {
if (localNetworkPermissionDialog == LocalNetworkPermissionDialog.Settings) { if (localNetworkPermissionDialog == LocalNetworkPermissionDialog.Settings) {
localNetworkPermissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog) localNetworkPermissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
} else { } else {
@@ -188,7 +190,7 @@ class LoggedInPresenter(
} }
return LoggedInState( return LoggedInState(
showSyncSpinner = showSyncSpinner, syncIndicatorState = syncIndicatorState,
pusherRegistrationState = pusherRegistrationState.value, pusherRegistrationState = pusherRegistrationState.value,
ignoreRegistrationError = ignoreRegistrationError, ignoreRegistrationError = ignoreRegistrationError,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration, forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
@@ -11,12 +11,18 @@ package io.element.android.appnav.loggedin
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
enum class SyncIndicatorState {
Hidden,
Syncing,
ServerUnreachable,
}
data class LoggedInState( data class LoggedInState(
val showSyncSpinner: Boolean, val syncIndicatorState: SyncIndicatorState,
val pusherRegistrationState: AsyncData<Unit>, val pusherRegistrationState: AsyncData<Unit>,
val ignoreRegistrationError: Boolean, val ignoreRegistrationError: Boolean,
val forceNativeSlidingSyncMigration: Boolean, val forceNativeSlidingSyncMigration: Boolean,
val appName: String, val appName: String,
val localNetworkPermissionDialog: LocalNetworkPermissionDialog, val localNetworkPermissionDialog: LocalNetworkPermissionDialog,
val eventSink: (LoggedInEvents) -> Unit, val eventSink: (LoggedInEvent) -> Unit,
) )
@@ -17,7 +17,8 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState> override val values: Sequence<LoggedInState>
get() = sequenceOf( get() = sequenceOf(
aLoggedInState(), aLoggedInState(),
aLoggedInState(showSyncSpinner = true), aLoggedInState(syncIndicatorState = SyncIndicatorState.Syncing),
aLoggedInState(syncIndicatorState = SyncIndicatorState.ServerUnreachable),
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())), aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
aLoggedInState(forceNativeSlidingSyncMigration = true), aLoggedInState(forceNativeSlidingSyncMigration = true),
aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale), aLoggedInState(localNetworkPermissionDialog = LocalNetworkPermissionDialog.Rationale),
@@ -26,13 +27,13 @@ open class LoggedInStatePreviewParam : PreviewParameterProvider<LoggedInState> {
} }
fun aLoggedInState( fun aLoggedInState(
showSyncSpinner: Boolean = false, syncIndicatorState: SyncIndicatorState = SyncIndicatorState.Hidden,
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized, pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
forceNativeSlidingSyncMigration: Boolean = false, forceNativeSlidingSyncMigration: Boolean = false,
appName: String = "Element X", appName: String = "Element X",
localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None, localNetworkPermissionDialog: LocalNetworkPermissionDialog = LocalNetworkPermissionDialog.None,
) = LoggedInState( ) = LoggedInState(
showSyncSpinner = showSyncSpinner, syncIndicatorState = syncIndicatorState,
pusherRegistrationState = pusherRegistrationState, pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false, ignoreRegistrationError = false,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration, forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
@@ -37,7 +37,7 @@ fun LoggedInView(
) { ) {
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
state.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability) state.eventSink(LoggedInEvent.CheckSlidingSyncProxyAvailability)
} }
} }
Box( Box(
@@ -47,7 +47,7 @@ fun LoggedInView(
) { ) {
SyncStateView( SyncStateView(
modifier = Modifier.align(Alignment.TopCenter), modifier = Modifier.align(Alignment.TopCenter),
isVisible = state.showSyncSpinner, state = state.syncIndicatorState,
) )
} }
when (state.pusherRegistrationState) { when (state.pusherRegistrationState) {
@@ -63,10 +63,10 @@ fun LoggedInView(
content = stringResource(id = CommonStrings.common_error_registering_pusher_android, reason), content = stringResource(id = CommonStrings.common_error_registering_pusher_android, reason),
cancelText = stringResource(id = CommonStrings.common_settings), cancelText = stringResource(id = CommonStrings.common_settings),
onDismiss = { onDismiss = {
state.eventSink(LoggedInEvents.CloseErrorDialog(it)) state.eventSink(LoggedInEvent.CloseErrorDialog(it))
}, },
onCancel = { onCancel = {
state.eventSink(LoggedInEvents.CloseErrorDialog(false)) state.eventSink(LoggedInEvent.CloseErrorDialog(false))
navigateToNotificationTroubleshoot() navigateToNotificationTroubleshoot()
} }
) )
@@ -79,15 +79,15 @@ fun LoggedInView(
ForceNativeSlidingSyncMigrationDialog( ForceNativeSlidingSyncMigrationDialog(
appName = state.appName, appName = state.appName,
onSubmit = { onSubmit = {
state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync) state.eventSink(LoggedInEvent.LogoutAndMigrateToNativeSlidingSync)
} }
) )
} }
LocalNetworkPermissionDialogView( LocalNetworkPermissionDialogView(
dialog = state.localNetworkPermissionDialog, dialog = state.localNetworkPermissionDialog,
onSubmit = { state.eventSink(LoggedInEvents.RequestLocationNetworkPermission) }, onSubmit = { state.eventSink(LoggedInEvent.RequestLocationNetworkPermission) },
onDismiss = { state.eventSink(LoggedInEvents.DismissLocalNetworkPermissionPrompt) }, onDismiss = { state.eventSink(LoggedInEvent.DismissLocalNetworkPermissionPrompt) },
) )
} }
@@ -25,18 +25,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun SyncStateView( fun SyncStateView(
isVisible: Boolean, state: SyncIndicatorState,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = state != SyncIndicatorState.Hidden,
modifier = modifier, modifier = modifier,
enter = fadeIn(spring(stiffness = 500F)), enter = fadeIn(spring(stiffness = 500F)),
exit = fadeOut(spring(stiffness = 500F)), exit = fadeOut(spring(stiffness = 500F)),
) { ) {
AsyncIndicator.Loading( when (state) {
text = stringResource(id = CommonStrings.common_syncing), SyncIndicatorState.ServerUnreachable -> AsyncIndicator.Failure(
) text = stringResource(id = CommonStrings.common_server_unreachable),
)
else -> AsyncIndicator.Loading(
text = stringResource(id = CommonStrings.common_syncing),
)
}
} }
} }
@@ -46,7 +51,18 @@ internal fun SyncStateViewPreview() = ElementPreview {
// Add a box to see the shadow // Add a box to see the shadow
Box(modifier = Modifier.padding(24.dp)) { Box(modifier = Modifier.padding(24.dp)) {
SyncStateView( SyncStateView(
isVisible = true state = SyncIndicatorState.Syncing
)
}
}
@PreviewsDayNight
@Composable
internal fun SyncStateViewServerUnreachablePreview() = ElementPreview {
// Add a box to see the shadow
Box(modifier = Modifier.padding(24.dp)) {
SyncStateView(
state = SyncIndicatorState.ServerUnreachable
) )
} }
} }
@@ -17,9 +17,6 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -70,7 +67,7 @@ class LoggedInPresenterTest {
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
createLoggedInPresenter().test { createLoggedInPresenter().test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse() assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden)
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue() assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(initialState.ignoreRegistrationError).isFalse() assertThat(initialState.ignoreRegistrationError).isFalse()
} }
@@ -100,11 +97,33 @@ class LoggedInPresenterTest {
matrixClient = FakeMatrixClient(roomListService = roomListService), matrixClient = FakeMatrixClient(roomListService = roomListService),
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse() assertThat(initialState.syncIndicatorState).isEqualTo(SyncIndicatorState.Hidden)
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show) roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
consumeItemsUntilPredicate { it.showSyncSpinner } consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Syncing }
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Hide) roomListService.postSyncIndicator(RoomListService.SyncIndicator.Hide)
consumeItemsUntilPredicate { !it.showSyncSpinner } consumeItemsUntilPredicate { it.syncIndicatorState != SyncIndicatorState.Syncing }
}
}
@Test
fun `present - say the server is unreachable when the device has network but the sync is offline`() = runTest {
createLoggedInPresenter(
syncState = SyncState.Offline,
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
).test {
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.ServerUnreachable }
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `present - say nothing when the device itself has no network`() = runTest {
createLoggedInPresenter(
syncState = SyncState.Offline,
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected),
).test {
consumeItemsUntilPredicate { it.syncIndicatorState == SyncIndicatorState.Hidden }
cancelAndConsumeRemainingEvents()
} }
} }
@@ -131,7 +150,6 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test { ).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -214,7 +232,7 @@ class LoggedInPresenterTest {
lambda.assertions() lambda.assertions()
.isCalledOnce() .isCalledOnce()
// Reset the error and do not show again // Reset the error and do not show again
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false)) finalState.eventSink(LoggedInEvent.CloseErrorDialog(doNotShowAgain = false))
val lastState = awaitItem() val lastState = awaitItem()
assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue() assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(lastState.ignoreRegistrationError).isFalse() assertThat(lastState.ignoreRegistrationError).isFalse()
@@ -244,7 +262,7 @@ class LoggedInPresenterTest {
lambda.assertions() lambda.assertions()
.isCalledOnce() .isCalledOnce()
// Reset the error and do not show again // Reset the error and do not show again
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true)) finalState.eventSink(LoggedInEvent.CloseErrorDialog(doNotShowAgain = true))
skipItems(1) skipItems(1)
setIgnoreRegistrationErrorLambda.assertions() setIgnoreRegistrationErrorLambda.assertions()
.isCalledOnce() .isCalledOnce()
@@ -300,7 +318,7 @@ class LoggedInPresenterTest {
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse() assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability) initialState.eventSink(LoggedInEvent.CheckSlidingSyncProxyAvailability)
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue() assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
} }
} }
@@ -322,7 +340,7 @@ class LoggedInPresenterTest {
).test { ).test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync) initialState.eventSink(LoggedInEvent.LogoutAndMigrateToNativeSlidingSync)
advanceUntilIdle() advanceUntilIdle()
@@ -362,9 +380,6 @@ class LoggedInPresenterTest {
} }
createLoggedInPresenter( createLoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test { ).test {
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda) assert(enableAutomaticCallStatusLambda)
@@ -374,7 +389,7 @@ class LoggedInPresenterTest {
} }
@Test @Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest { fun `present - enables automatic call status when server supports it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { } val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) }, accountManagementUrlResult = { Result.success(null) },
@@ -382,23 +397,13 @@ class LoggedInPresenterTest {
).apply { ).apply {
isUserStatusSupportedResult = Result.success(true) isUserStatusSupportedResult = Result.success(true)
} }
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter( createLoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test { ).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda) assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2) .isCalledOnce()
.withSequence( .with(value(true))
listOf(value(false)),
listOf(value(true)),
)
} }
} }
@@ -418,7 +423,6 @@ class LoggedInPresenterTest {
), ),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
@@ -430,7 +434,6 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
) )
} }
+1 -1
View File
@@ -322,7 +322,7 @@ Example for a new feature called RoomDetails:
4. Verify that the structure looks ok and click on Finish 4. Verify that the structure looks ok and click on Finish
5. The modules api/impl should be created under `features/roomdetails` directory. 5. The modules api/impl should be created under `features/roomdetails` directory.
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle). 6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
7. You can now add more Presentation classes (Events, State, StatePreviewParam, View, Presenter) in the impl module with the `Template Presentation Classes`. 7. You can now add more Presentation classes (Event, State, StatePreviewParam, View, Presenter) in the impl module with the `Template Presentation Classes`.
To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`. To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`, Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
`<BaseName>StatePreviewParam.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance `<BaseName>StatePreviewParam.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
+6 -1
View File
@@ -9,13 +9,18 @@
1) Set `127.0.0.1` as "Host name" and `8080` as "Port number". 1) Set `127.0.0.1` as "Host name" and `8080` as "Port number".
1) Click "Apply" and verify that "Proxy status" is "Success" and close the settings window. 1) Click "Apply" and verify that "Proxy status" is "Success" and close the settings window.
<img width="932" alt="Screenshot 2023-10-04 at 14 48 47" src="https://github.com/element-hq/element-x-android/assets/1273124/bf99a053-20b0-42a4-91d3-9602f709f684"> <img width="932" alt="Screenshot 2023-10-04 at 14 48 47" src="https://github.com/element-hq/element-x-android/assets/1273124/bf99a053-20b0-42a4-91d3-9602f709f684">
1) Alternatively, set the proxy on the device itself, which the app honours through `DefaultProxyProvider`, and which also works on a physical device:
```
adb shell settings put global http_proxy 10.0.2.2:8080
```
Use `adb shell settings delete global http_proxy` to remove it afterwards. On a physical device, replace `10.0.2.2` with the address of the machine running mitmproxy.
1) Install the mitmproxy CA cert (this is needed to see traffic from java/kotlin code, it's not needed for traffic coming from native code e.g. the matrix-rust-sdk). 1) Install the mitmproxy CA cert (this is needed to see traffic from java/kotlin code, it's not needed for traffic coming from native code e.g. the matrix-rust-sdk).
1) Open the emulator Chrome browser app 1) Open the emulator Chrome browser app
1) Go to the url `mitm.it` 1) Go to the url `mitm.it`
1) Follow the instructions to install the CA cert on Android devices. 1) Follow the instructions to install the CA cert on Android devices.
<img width="606" alt="Screenshot 2023-10-04 at 14 51 27" src="https://github.com/element-hq/element-x-android/assets/1273124/5f2b6f27-6958-4ea7-97fe-c7f06d105da5"> <img width="606" alt="Screenshot 2023-10-04 at 14 51 27" src="https://github.com/element-hq/element-x-android/assets/1273124/5f2b6f27-6958-4ea7-97fe-c7f06d105da5">
1) Slightly modify the Element X app source code. 1) Slightly modify the Element X app source code.
1) Go to the `RustMatrixClientFactory.create()` method. 1) Go to the `RustMatrixClientFactory.getBaseClientBuilder()` method, which builds every client the app creates, whether it is logging in or restoring a session.
1) Add `.disableSslVerification()` in the `ClientBuilder` method chain. 1) Add `.disableSslVerification()` in the `ClientBuilder` method chain.
1) Build and run the Element X app. 1) Build and run the Element X app.
1) Enjoy, you will see all the traffic in mitmproxy's web interface. 1) Enjoy, you will see all the traffic in mitmproxy's web interface.
+53
View File
@@ -0,0 +1,53 @@
# Element Call
<!--- TOC -->
* [Overview](#overview)
* [Setting a custom base URL](#setting-a-custom-base-url)
* [Why the application does not add the room path itself](#why-the-application-does-not-add-the-room-path-itself)
* [Where the value is read](#where-the-value-is-read)
<!--- END -->
## Overview
Element X embeds a copy of [Element Call](https://github.com/element-hq/element-call) in the
application assets and loads it from
`https://appassets.androidplatform.net/element-call/index.html`. Calls therefore work out of the box
with no extra deployment, and the embedded copy is the one every user gets.
Developers can point the application at another Element Call deployment instead — a local
development server, a staging deployment, or a self-hosted one — with the **Element Call base URL**
developer option.
## Setting a custom base URL
The option lives in *Settings* → *Developer options* → *Element Call*, under "Element Call base
URL". On a release build the *Developer options* entry is hidden until the version number at the
bottom of *Settings* is tapped seven times. Leaving the field empty restores the embedded copy.
The value is used verbatim as the widget base URL, so it must be the URL of the page that joins a
room, not the URL of the deployment root. For the public deployments that means:
| Deployment | Value to enter |
|---|---|
| `call.element.io` | `https://call.element.io/room` |
| `call.element.dev` | `https://call.element.dev/room` |
Entering `https://call.element.io` without the `/room` suffix loads the Element Call lobby rather
than the room, so the call is never joined.
## Why the application does not add the room path itself
The path that joins a room is a property of the deployment, not of Element Call. Unpacking the
Element Call release tarball behind a reverse proxy can put it at any path — `https://call.foo.tld`,
`https://call.foo.tld/some/path/somewhere` — and a proxy that already maps a subdomain onto
`https://1.2.3.4/room` would end up with the suffix twice. The application passes the value through
unchanged so that every one of those deployments can be reached.
## Where the value is read
`AppDeveloperSettingsView` renders the field and sends
`AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl`, which `DefaultAppPreferencesStore` persists.
`DefaultCallWidgetProvider.getWidget` reads it back and falls back to the embedded copy when it is
unset.
+3 -3
View File
@@ -64,16 +64,16 @@ brew install bundletool
### Steps ### Steps
1. Open the GitHub release that you want to install from https://github.com/element-hq/element-x-android/releases 1. Open the GitHub release that you want to install from https://github.com/element-hq/element-x-android/releases
2. Download the asset `app-release-signed.aab` 2. Download the asset `app-gplay-release-signed.aab`
3. Navigate to the folder where you cloned the project and run the following command: 3. Navigate to the folder where you cloned the project and run the following command:
```bash ```bash
bundletool build-apks --bundle=<PATH_TO_YOUR_APP-RELEASE-SIGNED.AAB_FILE> --output=./tmp/elementx.apks \ bundletool build-apks --bundle=<PATH_TO_YOUR_APP-GPLAY-RELEASE-SIGNED.AAB_FILE> --output=./tmp/elementx.apks \
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \ --ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
--overwrite --overwrite
``` ```
For instance: For instance:
```bash ```bash
bundletool build-apks --bundle=./tmp/Element/0.1.5/app-release-signed.aab --output=./tmp/elementx.apks \ bundletool build-apks --bundle=./tmp/Element/26.08.3/app-gplay-release-signed.aab --output=./tmp/elementx.apks \
--ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \ --ks=./app/signature/debug.keystore --ks-pass=pass:android --ks-key-alias=androiddebugkey --key-pass=pass:android \
--overwrite --overwrite
``` ```
-2
View File
@@ -6,8 +6,6 @@ Rust sdk branch: https://github.com/matrix-org/matrix-rust-sdk/tree/oidc-ffi
Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0 Figma https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?node-id=133-5426&t=yQXKeANatk6keoZF-0
Server list: https://github.com/element-hq/oidc-playground
Metadata iOS: (from https://github.com/element-hq/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28) Metadata iOS: (from https://github.com/element-hq/element-x-ios/blob/5f9d07377cebc4f21d9668b1a25f6e3bb22f64a1/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift#L28)
clientName: InfoPlistReader.main.bundleDisplayName, clientName: InfoPlistReader.main.bundleDisplayName,
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -8,6 +8,6 @@
package io.element.android.features.analytics.api package io.element.android.features.analytics.api
sealed interface AnalyticsOptInEvents { sealed interface AnalyticsOptInEvent {
data class EnableAnalytics(val isEnabled: Boolean) : AnalyticsOptInEvents data class EnableAnalytics(val isEnabled: Boolean) : AnalyticsOptInEvent
} }
@@ -8,11 +8,11 @@
package io.element.android.features.analytics.api.preferences package io.element.android.features.analytics.api.preferences
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvent
data class AnalyticsPreferencesState( data class AnalyticsPreferencesState(
val applicationName: String, val applicationName: String,
val isEnabled: Boolean, val isEnabled: Boolean,
val policyUrl: String, val policyUrl: String,
val eventSink: (AnalyticsOptInEvents) -> Unit, val eventSink: (AnalyticsOptInEvent) -> Unit,
) )
@@ -13,7 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.features.analytics.api.R import io.element.android.features.analytics.api.R
import io.element.android.libraries.designsystem.components.LINK_TAG import io.element.android.libraries.designsystem.components.LINK_TAG
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
@@ -30,7 +30,7 @@ fun AnalyticsPreferencesView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onEnabledChanged(isEnabled: Boolean) { fun onEnabledChanged(isEnabled: Boolean) {
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled)) state.eventSink(AnalyticsOptInEvent.EnableAnalytics(isEnabled = isEnabled))
} }
val supportingText = stringResource( val supportingText = stringResource(
@@ -12,7 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appconfig.AnalyticsConfig import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@@ -28,9 +28,9 @@ class AnalyticsOptInPresenter(
override fun present(): AnalyticsOptInState { override fun present(): AnalyticsOptInState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
fun handleEvent(event: AnalyticsOptInEvents) { fun handleEvent(event: AnalyticsOptInEvent) {
when (event) { when (event) {
is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled) is AnalyticsOptInEvent.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
} }
localCoroutineScope.launch { localCoroutineScope.launch {
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
@@ -8,10 +8,10 @@
package io.element.android.features.analytics.impl package io.element.android.features.analytics.impl
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvent
data class AnalyticsOptInState( data class AnalyticsOptInState(
val applicationName: String, val applicationName: String,
val hasPolicyLink: Boolean, val hasPolicyLink: Boolean,
val eventSink: (AnalyticsOptInEvents) -> Unit val eventSink: (AnalyticsOptInEvent) -> Unit
) )
@@ -28,7 +28,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.appconfig.AnalyticsConfig import io.element.android.appconfig.AnalyticsConfig
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule 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.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -55,11 +55,11 @@ fun AnalyticsOptInView(
val eventSink = state.eventSink val eventSink = state.eventSink
fun onAcceptTerms() { fun onAcceptTerms() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) eventSink(AnalyticsOptInEvent.EnableAnalytics(true))
} }
fun onDeclineTerms() { fun onDeclineTerms() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) eventSink(AnalyticsOptInEvent.EnableAnalytics(false))
} }
BackHandler(onBack = ::onDeclineTerms) BackHandler(onBack = ::onDeclineTerms)
@@ -13,7 +13,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appconfig.AnalyticsConfig import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
@@ -31,9 +31,9 @@ class AnalyticsPreferencesPresenter(
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false) val isEnabled = analyticsService.userConsentFlow.collectAsState(initial = false)
fun handleEvent(event: AnalyticsOptInEvents) { fun handleEvent(event: AnalyticsOptInEvent) {
when (event) { when (event) {
is AnalyticsOptInEvents.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled) is AnalyticsOptInEvent.EnableAnalytics -> localCoroutineScope.setIsEnabled(event.isEnabled)
} }
} }
@@ -12,7 +12,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@@ -37,7 +37,7 @@ class AnalyticsOptInPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse() assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true)) initialState.eventSink.invoke(AnalyticsOptInEvent.EnableAnalytics(true))
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue() assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
assertThat(analyticsService.userConsentFlow.first()).isTrue() assertThat(analyticsService.userConsentFlow.first()).isTrue()
} }
@@ -55,7 +55,7 @@ class AnalyticsOptInPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse() assertThat(analyticsService.didAskUserConsentFlow.first()).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false)) initialState.eventSink.invoke(AnalyticsOptInEvent.EnableAnalytics(false))
assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue() assertThat(analyticsService.didAskUserConsentFlow.first()).isTrue()
assertThat(analyticsService.userConsentFlow.first()).isFalse() assertThat(analyticsService.userConsentFlow.first()).isFalse()
} }
@@ -13,7 +13,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AnalyticsConfig import io.element.android.appconfig.AnalyticsConfig
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvent
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@@ -67,9 +67,9 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue() assertThat(initialState.isEnabled).isTrue()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(false)) initialState.eventSink.invoke(AnalyticsOptInEvent.EnableAnalytics(false))
assertThat(awaitItem().isEnabled).isFalse() assertThat(awaitItem().isEnabled).isFalse()
initialState.eventSink.invoke(AnalyticsOptInEvents.EnableAnalytics(true)) initialState.eventSink.invoke(AnalyticsOptInEvent.EnableAnalytics(true))
assertThat(awaitItem().isEnabled).isTrue() assertThat(awaitItem().isEnabled).isTrue()
} }
} }
@@ -21,7 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.features.invitepeople.api.InvitePeopleEvent
import io.element.android.features.invitepeople.api.InvitePeopleState import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.features.invitepeople.api.InvitePeopleStatePreviewParam import io.element.android.features.invitepeople.api.InvitePeopleStatePreviewParam
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -56,7 +56,7 @@ fun AddPeopleView(
Button( Button(
text = stringResource(CommonStrings.action_finish), text = stringResource(CommonStrings.action_finish),
onClick = { onClick = {
state.eventSink(InvitePeopleEvents.SendInvites) state.eventSink(InvitePeopleEvent.SendInvites)
}, },
enabled = state.canInvite, enabled = state.canInvite,
modifier = Modifier modifier = Modifier
@@ -11,13 +11,13 @@ package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents { sealed interface ConfigureRoomEvent {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents data class RoomNameChanged(val name: String) : ConfigureRoomEvent
data class TopicChanged(val topic: String) : ConfigureRoomEvents data class TopicChanged(val topic: String) : ConfigureRoomEvent
data class JoinRuleChanged(val joinRuleItem: JoinRuleItem) : ConfigureRoomEvents data class JoinRuleChanged(val joinRuleItem: JoinRuleItem) : ConfigureRoomEvent
data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvent
data object CreateRoom : ConfigureRoomEvents data object CreateRoom : ConfigureRoomEvent
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvent
data class SetParentSpace(val space: SpaceRoom?) : ConfigureRoomEvents data class SetParentSpace(val space: SpaceRoom?) : ConfigureRoomEvent
data object CancelCreateRoom : ConfigureRoomEvents data object CancelCreateRoom : ConfigureRoomEvent
} }
@@ -188,14 +188,14 @@ class ConfigureRoomPresenter(
localCoroutineScope.createRoom(config, createRoomAction) localCoroutineScope.createRoom(config, createRoomAction)
} }
fun handleEvent(event: ConfigureRoomEvents) { fun handleEvent(event: ConfigureRoomEvent) {
when (event) { when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) is ConfigureRoomEvent.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) is ConfigureRoomEvent.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.JoinRuleChanged -> dataStore.setJoinRule(event.joinRuleItem) is ConfigureRoomEvent.JoinRuleChanged -> dataStore.setJoinRule(event.joinRuleItem)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress) is ConfigureRoomEvent.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig) is ConfigureRoomEvent.CreateRoom -> createRoom(createRoomConfig)
is ConfigureRoomEvents.HandleAvatarAction -> { is ConfigureRoomEvent.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
@@ -207,10 +207,10 @@ class ConfigureRoomPresenter(
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
} }
} }
is ConfigureRoomEvents.SetParentSpace -> { is ConfigureRoomEvent.SetParentSpace -> {
dataStore.setParentSpace(event.space, false) dataStore.setParentSpace(event.space, false)
} }
ConfigureRoomEvents.CancelCreateRoom -> { ConfigureRoomEvent.CancelCreateRoom -> {
createRoomAction.value = AsyncAction.Uninitialized createRoomAction.value = AsyncAction.Uninitialized
} }
} }
@@ -26,7 +26,7 @@ data class ConfigureRoomState(
val homeserverName: String, val homeserverName: String,
val availableJoinRules: ImmutableList<JoinRuleItem>, val availableJoinRules: ImmutableList<JoinRuleItem>,
val spaces: ImmutableList<SpaceRoom>, val spaces: ImmutableList<SpaceRoom>,
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvent) -> Unit
) { ) {
val isValid: Boolean = config.roomName?.isNotEmpty() == true && val isValid: Boolean = config.roomName?.isNotEmpty() == true &&
(config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid) && (config.visibilityState is RoomVisibilityState.Private || roomAddressValidity == RoomAddressValidity.Valid) &&
@@ -143,7 +143,7 @@ fun aConfigureRoomState(
) )
}, },
spaces: List<SpaceRoom> = emptyList(), spaces: List<SpaceRoom> = emptyList(),
eventSink: (ConfigureRoomEvents) -> Unit = { }, eventSink: (ConfigureRoomEvent) -> Unit = { },
) = ConfigureRoomState( ) = ConfigureRoomState(
config = config, config = config,
isSpace = isSpace, isSpace = isSpace,
@@ -96,7 +96,7 @@ fun ConfigureRoomView(
onBackClick = onBackClick, onBackClick = onBackClick,
onNextClick = { onNextClick = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.CreateRoom) state.eventSink(ConfigureRoomEvent.CreateRoom)
}, },
) )
} }
@@ -114,20 +114,20 @@ fun ConfigureRoomView(
avatarUri = state.config.avatarUri, avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(), roomName = state.config.roomName.orEmpty(),
onAvatarClick = ::onAvatarClick, onAvatarClick = ::onAvatarClick,
onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, onChangeRoomName = { state.eventSink(ConfigureRoomEvent.RoomNameChanged(it)) },
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
RoomTopic( RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
topic = state.config.topic.orEmpty(), topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, onTopicChange = { state.eventSink(ConfigureRoomEvent.TopicChanged(it)) },
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (!state.isSpace && state.spaces.isNotEmpty()) { if (!state.isSpace && state.spaces.isNotEmpty()) {
SelectParentSpaceOptions( SelectParentSpaceOptions(
spaces = state.spaces, spaces = state.spaces,
selectedSpace = state.config.parentSpace, selectedSpace = state.config.parentSpace,
onSelectSpace = { state.eventSink(ConfigureRoomEvents.SetParentSpace(it)) }, onSelectSpace = { state.eventSink(ConfigureRoomEvent.SetParentSpace(it)) },
) )
} }
RoomJoinRuleOptions( RoomJoinRuleOptions(
@@ -136,7 +136,7 @@ fun ConfigureRoomView(
parentSpace = state.config.parentSpace, parentSpace = state.config.parentSpace,
onOptionClick = { onOptionClick = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.JoinRuleChanged(it)) state.eventSink(ConfigureRoomEvent.JoinRuleChanged(it))
}, },
) )
if (state.config.visibilityState !is RoomVisibilityState.Private) { if (state.config.visibilityState !is RoomVisibilityState.Private) {
@@ -146,7 +146,7 @@ fun ConfigureRoomView(
address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(), address = state.config.visibilityState.roomAddress().getOrNull().orEmpty(),
homeserverName = state.homeserverName, homeserverName = state.homeserverName,
addressValidity = state.roomAddressValidity, addressValidity = state.roomAddressValidity,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) }, onAddressChange = { state.eventSink(ConfigureRoomEvent.RoomAddressChanged(it)) },
label = null, label = null,
supportingText = stringResource(R.string.screen_create_room_room_address_section_footer), supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
) )
@@ -158,7 +158,7 @@ fun ConfigureRoomView(
actions = state.avatarActions, actions = state.avatarActions,
isVisible = isAvatarActionsSheetVisible.value, isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false }, onDismiss = { isAvatarActionsSheetVisible.value = false },
onSelectAction = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } onSelectAction = { state.eventSink(ConfigureRoomEvent.HandleAvatarAction(it)) }
) )
AsyncActionView( AsyncActionView(
@@ -170,8 +170,8 @@ fun ConfigureRoomView(
}, },
onSuccess = { onCreateRoomSuccess(it) }, onSuccess = { onCreateRoomSuccess(it) },
errorMessage = { stringResource(if (isSpace) R.string.screen_create_room_error_creating_space else R.string.screen_create_room_error_creating_room) }, errorMessage = { stringResource(if (isSpace) R.string.screen_create_room_error_creating_space else R.string.screen_create_room_error_creating_room) },
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) }, onRetry = { state.eventSink(ConfigureRoomEvent.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) }, onErrorDismiss = { state.eventSink(ConfigureRoomEvent.CancelCreateRoom) },
) )
PermissionsView( PermissionsView(
@@ -12,7 +12,7 @@ import androidx.core.net.toUri
import app.cash.turbine.TurbineTestContext import app.cash.turbine.TurbineTestContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomEvents import io.element.android.features.createroom.impl.configureroom.ConfigureRoomEvent
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomPresenter import io.element.android.features.createroom.impl.configureroom.ConfigureRoomPresenter
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomState import io.element.android.features.createroom.impl.configureroom.ConfigureRoomState
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig
@@ -106,14 +106,14 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
assertThat(initialState.isValid).isFalse() assertThat(initialState.isValid).isFalse()
// Room name not empty // Room name not empty
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) initialState.eventSink(ConfigureRoomEvent.RoomNameChanged(A_ROOM_NAME))
var newState: ConfigureRoomState = awaitItem() var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME) config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config) assertThat(newState.config).isEqualTo(config)
assertThat(newState.isValid).isTrue() assertThat(newState.isValid).isTrue()
// Clear room name // Clear room name
newState.eventSink(ConfigureRoomEvents.RoomNameChanged("")) newState.eventSink(ConfigureRoomEvent.RoomNameChanged(""))
newState = awaitItem() newState = awaitItem()
config = config.copy(roomName = null) config = config.copy(roomName = null)
assertThat(newState.config).isEqualTo(config) assertThat(newState.config).isEqualTo(config)
@@ -136,13 +136,13 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
var expectedConfig = CreateRoomConfig() var expectedConfig = CreateRoomConfig()
assertThat(initialState.config).isEqualTo(expectedConfig) assertThat(initialState.config).isEqualTo(expectedConfig)
// Room name // Room name
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) initialState.eventSink(ConfigureRoomEvent.RoomNameChanged(A_ROOM_NAME))
var newState = awaitItem() var newState = awaitItem()
expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME) expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Room topic // Room topic
newState.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE)) newState.eventSink(ConfigureRoomEvent.TopicChanged(A_MESSAGE))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(topic = A_MESSAGE) expectedConfig = expectedConfig.copy(topic = A_MESSAGE)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
@@ -153,7 +153,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
// From gallery // From gallery
val uriFromGallery = AN_URI_FROM_GALLERY val uriFromGallery = AN_URI_FROM_GALLERY
pickerProvider.givenResult(uriFromGallery.toUri()) pickerProvider.givenResult(uriFromGallery.toUri())
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) newState.eventSink(ConfigureRoomEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery) expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
@@ -161,7 +161,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
val uriFromCamera = AN_URI_FROM_CAMERA val uriFromCamera = AN_URI_FROM_CAMERA
pickerProvider.givenResult(uriFromCamera.toUri()) pickerProvider.givenResult(uriFromCamera.toUri())
assertThat(newState.cameraPermissionState.permissionGranted).isFalse() assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState.eventSink(ConfigureRoomEvent.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem() newState = awaitItem()
assertThat(newState.cameraPermissionState.showDialog).isTrue() assertThat(newState.cameraPermissionState.showDialog).isTrue()
permissionsPresenter.setPermissionGranted() permissionsPresenter.setPermissionGranted()
@@ -173,18 +173,18 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
// Do it again, no permission is requested // Do it again, no permission is requested
val uriFromCamera2 = AN_URI_FROM_CAMERA_2 val uriFromCamera2 = AN_URI_FROM_CAMERA_2
pickerProvider.givenResult(uriFromCamera2.toUri()) pickerProvider.givenResult(uriFromCamera2.toUri())
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState.eventSink(ConfigureRoomEvent.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2) expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Remove // Remove
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove)) newState.eventSink(ConfigureRoomEvent.HandleAvatarAction(AvatarAction.Remove))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = null) expectedConfig = expectedConfig.copy(avatarUri = null)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy // Room privacy
newState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public)) newState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy( expectedConfig = expectedConfig.copy(
visibilityState = RoomVisibilityState.Public( visibilityState = RoomVisibilityState.Public(
@@ -208,7 +208,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
matrixClient.givenCreateRoomResult(createRoomResult) matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom) initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@@ -239,13 +239,13 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
// Use a public parent space so AskToJoin is a valid option // Use a public parent space so AskToJoin is a valid option
val parentSpace = aSpaceRoom(joinRule = JoinRule.Public) val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace)) initialState.eventSink(ConfigureRoomEvent.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace) assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin)) initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin) assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin)
initialState.eventSink(ConfigureRoomEvents.CreateRoom) initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
@@ -282,13 +282,13 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
// Use a public parent space so AskToJoin is a valid option // Use a public parent space so AskToJoin is a valid option
val parentSpace = aSpaceRoom(joinRule = JoinRule.Public) val parentSpace = aSpaceRoom(joinRule = JoinRule.Public)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(parentSpace)) initialState.eventSink(ConfigureRoomEvent.SetParentSpace(parentSpace))
assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace) assertThat(awaitItem().config.parentSpace).isEqualTo(parentSpace)
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin)) initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.AskToJoin))
assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin) assertThat(awaitItem().config.visibilityState.joinRuleItem).isEqualTo(JoinRuleItem.PublicVisibility.AskToJoin)
initialState.eventSink(ConfigureRoomEvents.CreateRoom) initialState.eventSink(ConfigureRoomEvent.CreateRoom)
// We immediately receive the room power levels info needed for adding the child to a space // We immediately receive the room power levels info needed for adding the child to a space
val powerLevels = RoomPowerLevels( val powerLevels = RoomPowerLevels(
@@ -339,7 +339,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
matrixClient.givenCreateRoomResult(createRoomResult) matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom) initialState.eventSink(ConfigureRoomEvent.CreateRoom)
skipItems(2) skipItems(2)
val analyticsEvent = analyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull() val analyticsEvent = analyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
@@ -369,14 +369,14 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(file, mockk(), mockk()))) mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(file, mockk(), mockk())))
matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION)) matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
initialState.eventSink(ConfigureRoomEvents.CreateRoom) initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(analyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() assertThat(analyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom) stateAfterCreateRoom.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@@ -399,14 +399,14 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
fakeMatrixClient.givenCreateRoomResult(createRoomResult) fakeMatrixClient.givenCreateRoomResult(createRoomResult)
// Create // Create
initialState.eventSink(ConfigureRoomEvents.CreateRoom) initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull()) assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Retry // Retry
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom) stateAfterCreateRoom.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterRetry = awaitItem() val stateAfterRetry = awaitItem()
@@ -414,7 +414,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
assertThat((stateAfterRetry.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull()) assertThat((stateAfterRetry.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Cancel // Cancel
stateAfterRetry.eventSink(ConfigureRoomEvents.CancelCreateRoom) stateAfterRetry.eventSink(ConfigureRoomEvent.CancelCreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
} }
@@ -430,9 +430,9 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public)) initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("invalid address")) initialState.eventSink(ConfigureRoomEvent.RoomAddressChanged("invalid address"))
skipItems(1) skipItems(1)
advanceUntilIdle() advanceUntilIdle()
awaitItem().also { state -> awaitItem().also { state ->
@@ -450,9 +450,9 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public)) initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address")) initialState.eventSink(ConfigureRoomEvent.RoomAddressChanged("address"))
skipItems(1) skipItems(1)
advanceUntilIdle() advanceUntilIdle()
awaitItem().also { state -> awaitItem().also { state ->
@@ -470,9 +470,9 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
) )
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public)) initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) skipItems(1)
initialState.eventSink(ConfigureRoomEvents.RoomAddressChanged("address")) initialState.eventSink(ConfigureRoomEvent.RoomAddressChanged("address"))
skipItems(1) skipItems(1)
advanceUntilIdle() advanceUntilIdle()
awaitItem().also { state -> awaitItem().also { state ->
@@ -488,22 +488,22 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
val initialState = initialState() val initialState = initialState()
// First change the join rule to public // First change the join rule to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public)) initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java) assertThat(awaitItem().config.visibilityState).isInstanceOf(RoomVisibilityState.Public::class.java)
// Then check changing the parent space resets it to private // Then check changing the parent space resets it to private
// (via LaunchedEffect fallback since Public is not in availableJoinRules for non-public parent) // (via LaunchedEffect fallback since Public is not in availableJoinRules for non-public parent)
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom())) initialState.eventSink(ConfigureRoomEvent.SetParentSpace(aSpaceRoom()))
skipItems(1) // Skip intermediate state skipItems(1) // Skip intermediate state
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private)) assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
// If we change the join rule back to public // If we change the join rule back to public
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public)) initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PublicVisibility.Public))
skipItems(1) // Skip intermediate state (Public is still invalid) skipItems(1) // Skip intermediate state (Public is still invalid)
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private)) assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
// Then remove the parent space, the join rule stays private // Then remove the parent space, the join rule stays private
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(null)) initialState.eventSink(ConfigureRoomEvent.SetParentSpace(null))
assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private)) assertThat(awaitItem().config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private))
} }
} }
@@ -514,7 +514,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.SetParentSpace(aSpaceRoom())) initialState.eventSink(ConfigureRoomEvent.SetParentSpace(aSpaceRoom()))
assertThat(awaitError()) assertThat(awaitError())
} }
@@ -534,8 +534,8 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
presenter.test { presenter.test {
val initialState = initialState() val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PrivateVisibility.Private)) initialState.eventSink(ConfigureRoomEvent.JoinRuleChanged(JoinRuleItem.PrivateVisibility.Private))
initialState.eventSink(ConfigureRoomEvents.CreateRoom) initialState.eventSink(ConfigureRoomEvent.CreateRoom)
assertThat(awaitItem().createRoomAction.isLoading()).isTrue() assertThat(awaitItem().createRoomAction.isLoading()).isTrue()
assertThat(awaitItem().createRoomAction.isSuccess()).isTrue() assertThat(awaitItem().createRoomAction.isSuccess()).isTrue()
@@ -8,9 +8,9 @@
package io.element.android.features.logout.impl package io.element.android.features.logout.impl
sealed interface AccountDeactivationEvents { sealed interface AccountDeactivationEvent {
data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvents data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvent
data class SetPassword(val password: String) : AccountDeactivationEvents data class SetPassword(val password: String) : AccountDeactivationEvent
data class DeactivateAccount(val isRetry: Boolean) : AccountDeactivationEvents data class DeactivateAccount(val isRetry: Boolean) : AccountDeactivationEvent
data object CloseDialogs : AccountDeactivationEvents data object CloseDialogs : AccountDeactivationEvent
} }
@@ -34,19 +34,19 @@ class AccountDeactivationPresenter(
val formState = remember { mutableStateOf(DeactivateFormState.Default) } val formState = remember { mutableStateOf(DeactivateFormState.Default) }
fun handleEvent(event: AccountDeactivationEvents) { fun handleEvent(event: AccountDeactivationEvent) {
when (event) { when (event) {
is AccountDeactivationEvents.SetEraseData -> { is AccountDeactivationEvent.SetEraseData -> {
updateFormState(formState) { updateFormState(formState) {
copy(eraseData = event.eraseData) copy(eraseData = event.eraseData)
} }
} }
is AccountDeactivationEvents.SetPassword -> { is AccountDeactivationEvent.SetPassword -> {
updateFormState(formState) { updateFormState(formState) {
copy(password = event.password) copy(password = event.password)
} }
} }
is AccountDeactivationEvents.DeactivateAccount -> is AccountDeactivationEvent.DeactivateAccount ->
if (action.value.isConfirming() || event.isRetry) { if (action.value.isConfirming() || event.isRetry) {
localCoroutineScope.deactivateAccount( localCoroutineScope.deactivateAccount(
formState = formState.value, formState = formState.value,
@@ -55,7 +55,7 @@ class AccountDeactivationPresenter(
} else { } else {
action.value = AsyncAction.ConfirmingNoParams action.value = AsyncAction.ConfirmingNoParams
} }
AccountDeactivationEvents.CloseDialogs -> { AccountDeactivationEvent.CloseDialogs -> {
action.value = AsyncAction.Uninitialized action.value = AsyncAction.Uninitialized
} }
} }
@@ -15,7 +15,7 @@ import kotlinx.parcelize.Parcelize
data class AccountDeactivationState( data class AccountDeactivationState(
val deactivateFormState: DeactivateFormState, val deactivateFormState: DeactivateFormState,
val accountDeactivationAction: AsyncAction<Unit>, val accountDeactivationAction: AsyncAction<Unit>,
val eventSink: (AccountDeactivationEvents) -> Unit, val eventSink: (AccountDeactivationEvent) -> Unit,
) { ) {
val submitEnabled: Boolean val submitEnabled: Boolean
get() = accountDeactivationAction is AsyncAction.Uninitialized && get() = accountDeactivationAction is AsyncAction.Uninitialized &&
@@ -45,7 +45,7 @@ internal fun aDeactivateFormState(
internal fun anAccountDeactivationState( internal fun anAccountDeactivationState(
deactivateFormState: DeactivateFormState = aDeactivateFormState(), deactivateFormState: DeactivateFormState = aDeactivateFormState(),
accountDeactivationAction: AsyncAction<Unit> = AsyncAction.Uninitialized, accountDeactivationAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (AccountDeactivationEvents) -> Unit = {}, eventSink: (AccountDeactivationEvent) -> Unit = {},
) = AccountDeactivationState( ) = AccountDeactivationState(
deactivateFormState = deactivateFormState, deactivateFormState = deactivateFormState,
accountDeactivationAction = accountDeactivationAction, accountDeactivationAction = accountDeactivationAction,
@@ -102,14 +102,14 @@ fun AccountDeactivationView(
Content( Content(
state = state, state = state,
onSubmitClick = { onSubmitClick = {
eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
} }
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Buttons( Buttons(
state = state, state = state,
onSubmitClick = { onSubmitClick = {
eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
} }
) )
} }
@@ -117,13 +117,13 @@ fun AccountDeactivationView(
AccountDeactivationActionDialog( AccountDeactivationActionDialog(
state.accountDeactivationAction, state.accountDeactivationAction,
onConfirmClick = { onConfirmClick = {
eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
}, },
onRetryClick = { onRetryClick = {
eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = true)) eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = true))
}, },
onDismissDialog = { onDismissDialog = {
eventSink(AccountDeactivationEvents.CloseDialogs) eventSink(AccountDeactivationEvent.CloseDialogs)
}, },
) )
} }
@@ -239,7 +239,7 @@ private fun Content(
headline = stringResource(R.string.screen_deactivate_account_delete_all_messages), headline = stringResource(R.string.screen_deactivate_account_delete_all_messages),
value = eraseData, value = eraseData,
onChange = { onChange = {
eventSink(AccountDeactivationEvents.SetEraseData(it)) eventSink(AccountDeactivationEvent.SetEraseData(it))
}, },
enabled = !isLoading, enabled = !isLoading,
) )
@@ -275,7 +275,7 @@ private fun Content(
onValueChange = { onValueChange = {
val sanitized = it.sanitize() val sanitized = it.sanitize()
passwordFieldState = sanitized passwordFieldState = sanitized
eventSink(AccountDeactivationEvents.SetPassword(sanitized)) eventSink(AccountDeactivationEvent.SetPassword(sanitized))
}, },
placeholder = stringResource(CommonStrings.common_password), placeholder = stringResource(CommonStrings.common_password),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
@@ -47,11 +47,11 @@ class AccountDeactivationPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.deactivateFormState).isEqualTo(DeactivateFormState.Default) assertThat(initialState.deactivateFormState).isEqualTo(DeactivateFormState.Default)
initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) initialState.eventSink(AccountDeactivationEvent.SetEraseData(true))
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.deactivateFormState).isEqualTo(DeactivateFormState.Default.copy(eraseData = true)) assertThat(updatedState.deactivateFormState).isEqualTo(DeactivateFormState.Default.copy(eraseData = true))
assertThat(updatedState.submitEnabled).isFalse() assertThat(updatedState.submitEnabled).isFalse()
updatedState.eventSink(AccountDeactivationEvents.SetPassword("password")) updatedState.eventSink(AccountDeactivationEvent.SetPassword("password"))
val updatedState2 = awaitItem() val updatedState2 = awaitItem()
assertThat(updatedState2.deactivateFormState).isEqualTo(DeactivateFormState(password = "password", eraseData = true)) assertThat(updatedState2.deactivateFormState).isEqualTo(DeactivateFormState(password = "password", eraseData = true))
assertThat(updatedState2.submitEnabled).isTrue() assertThat(updatedState2.submitEnabled).isTrue()
@@ -71,12 +71,12 @@ class AccountDeactivationPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) initialState.eventSink(AccountDeactivationEvent.SetPassword("password"))
skipItems(1) skipItems(1)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) initialState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) updatedState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem() val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem() val finalState = awaitItem()
@@ -98,20 +98,20 @@ class AccountDeactivationPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) initialState.eventSink(AccountDeactivationEvent.SetPassword("password"))
initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) initialState.eventSink(AccountDeactivationEvent.SetEraseData(true))
skipItems(2) skipItems(2)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) initialState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) updatedState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem() val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
recorder.assertions().isCalledOnce().with(value("password"), value(true)) recorder.assertions().isCalledOnce().with(value("password"), value(true))
// Retry // Retry
finalState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = true)) finalState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = true))
val finalState2 = awaitItem() val finalState2 = awaitItem()
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
assertThat(awaitItem().accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) assertThat(awaitItem().accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
@@ -131,20 +131,20 @@ class AccountDeactivationPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) initialState.eventSink(AccountDeactivationEvent.SetPassword("password"))
initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) initialState.eventSink(AccountDeactivationEvent.SetEraseData(true))
skipItems(2) skipItems(2)
initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) initialState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.ConfirmingNoParams)
updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) updatedState.eventSink(AccountDeactivationEvent.DeactivateAccount(isRetry = false))
val updatedState2 = awaitItem() val updatedState2 = awaitItem()
assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
recorder.assertions().isCalledOnce().with(value("password"), value(true)) recorder.assertions().isCalledOnce().with(value("password"), value(true))
// Cancel // Cancel
finalState.eventSink(AccountDeactivationEvents.CloseDialogs) finalState.eventSink(AccountDeactivationEvent.CloseDialogs)
val finalState2 = awaitItem() val finalState2 = awaitItem()
assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized) assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized)
} }
@@ -35,7 +35,7 @@ import org.robolectric.annotation.Config
class AccountDeactivationViewTest : RobolectricTest() { class AccountDeactivationViewTest : RobolectricTest() {
@Test @Test
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<AccountDeactivationEvent>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState(eventSink = eventsRecorder), state = anAccountDeactivationState(eventSink = eventsRecorder),
@@ -48,7 +48,7 @@ class AccountDeactivationViewTest : RobolectricTest() {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on Deactivate emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on Deactivate emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
@@ -58,12 +58,12 @@ class AccountDeactivationViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_delete) clickOn(CommonStrings.action_delete)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false)) eventsRecorder.assertSingle(AccountDeactivationEvent.DeactivateAccount(false))
} }
@Test @Test
fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
@@ -74,12 +74,12 @@ class AccountDeactivationViewTest : RobolectricTest() {
), ),
) )
pressTag(TestTags.dialogPositive.value) pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false)) eventsRecorder.assertSingle(AccountDeactivationEvent.DeactivateAccount(false))
} }
@Test @Test
fun `clicking on retry on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on retry on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
@@ -90,24 +90,24 @@ class AccountDeactivationViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_retry) clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(true)) eventsRecorder.assertSingle(AccountDeactivationEvent.DeactivateAccount(true))
} }
@Test @Test
fun `switching on the erase all switch emits the expected Event`() = runAndroidComposeUiTest { fun `switching on the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
clickOn(R.string.screen_deactivate_account_delete_all_messages) clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(true)) eventsRecorder.assertSingle(AccountDeactivationEvent.SetEraseData(true))
} }
@Test @Test
fun `switching off the erase all switch emits the expected Event`() = runAndroidComposeUiTest { fun `switching off the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
@@ -117,13 +117,13 @@ class AccountDeactivationViewTest : RobolectricTest() {
), ),
) )
clickOn(R.string.screen_deactivate_account_delete_all_messages) clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(false)) eventsRecorder.assertSingle(AccountDeactivationEvent.SetEraseData(false))
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `typing text in the password field emits the expected Event`() = runAndroidComposeUiTest { fun `typing text in the password field emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvent>()
setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
@@ -133,7 +133,7 @@ class AccountDeactivationViewTest : RobolectricTest() {
), ),
) )
onNodeWithTag(TestTags.loginPassword.value).performTextInput("A") onNodeWithTag(TestTags.loginPassword.value).performTextInput("A")
eventsRecorder.assertSingle(AccountDeactivationEvents.SetPassword("A$A_PASSWORD")) eventsRecorder.assertSingle(AccountDeactivationEvent.SetPassword("A$A_PASSWORD"))
} }
} }
@@ -8,6 +8,6 @@
package io.element.android.features.forward.impl package io.element.android.features.forward.impl
sealed interface ForwardMessagesEvents { sealed interface ForwardMessagesEvent {
data object ClearError : ForwardMessagesEvents data object ClearError : ForwardMessagesEvent
} }
@@ -48,9 +48,9 @@ class ForwardMessagesPresenter(
@Composable @Composable
override fun present(): ForwardMessagesState { override fun present(): ForwardMessagesState {
fun handleEvent(event: ForwardMessagesEvents) { fun handleEvent(event: ForwardMessagesEvent) {
when (event) { when (event) {
ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncAction.Uninitialized ForwardMessagesEvent.ClearError -> forwardingActionState.value = AsyncAction.Uninitialized
} }
} }
@@ -13,5 +13,5 @@ import io.element.android.libraries.matrix.api.core.RoomId
data class ForwardMessagesState( data class ForwardMessagesState(
val forwardAction: AsyncAction<List<RoomId>>, val forwardAction: AsyncAction<List<RoomId>>,
val eventSink: (ForwardMessagesEvents) -> Unit val eventSink: (ForwardMessagesEvent) -> Unit
) )
@@ -32,7 +32,7 @@ open class ForwardMessagesStatePreviewParam : PreviewParameterProvider<ForwardMe
fun aForwardMessagesState( fun aForwardMessagesState(
forwardAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized, forwardAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
eventSink: (ForwardMessagesEvents) -> Unit = {} eventSink: (ForwardMessagesEvent) -> Unit = {}
) = ForwardMessagesState( ) = ForwardMessagesState(
forwardAction = forwardAction, forwardAction = forwardAction,
eventSink = eventSink eventSink = eventSink
@@ -31,7 +31,7 @@ fun ForwardMessagesView(
stringResource(id = CommonStrings.error_unknown) stringResource(id = CommonStrings.error_unknown)
}, },
onErrorDismiss = { onErrorDismiss = {
state.eventSink(ForwardMessagesEvents.ClearError) state.eventSink(ForwardMessagesEvent.ClearError)
}, },
) )
} }
@@ -86,7 +86,7 @@ class ForwardMessagesPresenterTest {
val failedForwardState = awaitItem() val failedForwardState = awaitItem()
assertThat(failedForwardState.forwardAction.isFailure()).isTrue() assertThat(failedForwardState.forwardAction.isFailure()).isTrue()
// Then clear error // Then clear error
failedForwardState.eventSink(ForwardMessagesEvents.ClearError) failedForwardState.eventSink(ForwardMessagesEvent.ClearError)
assertThat(awaitItem().forwardAction.isUninitialized()).isTrue() assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
forwardEventLambda.assertions().isCalledOnce() forwardEventLambda.assertions().isCalledOnce()
} }
@@ -29,7 +29,7 @@ import org.junit.Test
class ForwardMessagesViewTest : RobolectricTest() { class ForwardMessagesViewTest : RobolectricTest() {
@Test @Test
fun `cancel error emits the expected event`() = runAndroidComposeUiTest { fun `cancel error emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>() val eventsRecorder = EventsRecorder<ForwardMessagesEvent>()
setForwardMessagesView( setForwardMessagesView(
aForwardMessagesState( aForwardMessagesState(
forwardAction = AsyncAction.Failure(AN_EXCEPTION), forwardAction = AsyncAction.Failure(AN_EXCEPTION),
@@ -37,13 +37,13 @@ class ForwardMessagesViewTest : RobolectricTest() {
), ),
) )
pressTag(TestTags.dialogPositive.value) pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError) eventsRecorder.assertSingle(ForwardMessagesEvent.ClearError)
} }
@Test @Test
fun `success invokes onForwardSuccess`() = runAndroidComposeUiTest { fun `success invokes onForwardSuccess`() = runAndroidComposeUiTest {
val data = listOf(A_ROOM_ID) val data = listOf(A_ROOM_ID)
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<ForwardMessagesEvent>(expectEvents = false)
ensureCalledOnceWithParam<List<RoomId>?>(data) { callback -> ensureCalledOnceWithParam<List<RoomId>?>(data) { callback ->
setForwardMessagesView( setForwardMessagesView(
aForwardMessagesState( aForwardMessagesState(
@@ -8,7 +8,7 @@
package io.element.android.features.ftue.impl.notifications package io.element.android.features.ftue.impl.notifications
sealed interface NotificationsOptInEvents { sealed interface NotificationsOptInEvent {
data object ContinueClicked : NotificationsOptInEvents data object ContinueClicked : NotificationsOptInEvent
data object NotNowClicked : NotificationsOptInEvents data object NotNowClicked : NotificationsOptInEvent
} }
@@ -52,16 +52,16 @@ class NotificationsOptInPresenter(
override fun present(): NotificationsOptInState { override fun present(): NotificationsOptInState {
val notificationsPermissionsState = postNotificationPermissionsPresenter.present() val notificationsPermissionsState = postNotificationPermissionsPresenter.present()
fun handleEvent(event: NotificationsOptInEvents) { fun handleEvent(event: NotificationsOptInEvent) {
when (event) { when (event) {
NotificationsOptInEvents.ContinueClicked -> { NotificationsOptInEvent.ContinueClicked -> {
if (notificationsPermissionsState.permissionGranted) { if (notificationsPermissionsState.permissionGranted) {
callback.onNotificationsOptInFinished() callback.onNotificationsOptInFinished()
} else { } else {
notificationsPermissionsState.eventSink(PermissionsEvent.RequestPermissions) notificationsPermissionsState.eventSink(PermissionsEvent.RequestPermissions)
} }
} }
NotificationsOptInEvents.NotNowClicked -> { NotificationsOptInEvent.NotNowClicked -> {
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
appCoroutineScope.setPermissionDenied() appCoroutineScope.setPermissionDenied()
} }
@@ -12,5 +12,5 @@ import io.element.android.libraries.permissions.api.PermissionsState
data class NotificationsOptInState( data class NotificationsOptInState(
val notificationsPermissionState: PermissionsState, val notificationsPermissionState: PermissionsState,
val eventSink: (NotificationsOptInEvents) -> Unit val eventSink: (NotificationsOptInEvent) -> Unit
) )
@@ -86,14 +86,14 @@ private fun NotificationsOptInFooter(state: NotificationsOptInState) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ok), text = stringResource(CommonStrings.action_ok),
onClick = { onClick = {
state.eventSink(NotificationsOptInEvents.ContinueClicked) state.eventSink(NotificationsOptInEvent.ContinueClicked)
} }
) )
TextButton( TextButton(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_not_now), text = stringResource(CommonStrings.action_not_now),
onClick = { onClick = {
state.eventSink(NotificationsOptInEvents.NotNowClicked) state.eventSink(NotificationsOptInEvent.NotNowClicked)
} }
) )
} }
@@ -15,7 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@@ -67,7 +67,7 @@ class ChooseSelfVerificationModePresenter(
fun handleEvent(event: ChooseSelfVerificationModeEvent) { fun handleEvent(event: ChooseSelfVerificationModeEvent) {
when (event) { when (event) {
ChooseSelfVerificationModeEvent.SignOut -> directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) ChooseSelfVerificationModeEvent.SignOut -> directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false))
} }
} }
@@ -55,7 +55,7 @@ class NotificationsOptInPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.ContinueClicked) initialState.eventSink(NotificationsOptInEvent.ContinueClicked)
assertThat(awaitItem().notificationsPermissionState.showDialog).isTrue() assertThat(awaitItem().notificationsPermissionState.showDialog).isTrue()
} }
} }
@@ -70,7 +70,7 @@ class NotificationsOptInPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.ContinueClicked) initialState.eventSink(NotificationsOptInEvent.ContinueClicked)
assertThat(isFinished).isTrue() assertThat(isFinished).isTrue()
} }
} }
@@ -86,7 +86,7 @@ class NotificationsOptInPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.NotNowClicked) initialState.eventSink(NotificationsOptInEvent.NotNowClicked)
assertThat(isFinished).isTrue() assertThat(isFinished).isTrue()
} }
} }
@@ -105,7 +105,7 @@ class NotificationsOptInPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.NotNowClicked) initialState.eventSink(NotificationsOptInEvent.NotNowClicked)
// Allow background coroutines to run // Allow background coroutines to run
runCurrent() runCurrent()
@@ -9,7 +9,7 @@
package io.element.android.features.ftue.impl.sessionverification.choosemode package io.element.android.features.ftue.impl.sessionverification.choosemode
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutEvent
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@@ -112,7 +112,7 @@ class ChooseSessionVerificationModePresenterTest {
@Test @Test
fun `sing out action triggers a direct logout`() = runTest { fun `sing out action triggers a direct logout`() = runTest {
val logoutEventRecorder = lambdaRecorder<DirectLogoutEvents, Unit> {} val logoutEventRecorder = lambdaRecorder<DirectLogoutEvent, Unit> {}
val logoutPresenter = Presenter<DirectLogoutState> { val logoutPresenter = Presenter<DirectLogoutState> {
aDirectLogoutState(eventSink = logoutEventRecorder) aDirectLogoutState(eventSink = logoutEventRecorder)
} }
@@ -121,7 +121,7 @@ class ChooseSessionVerificationModePresenterTest {
val initial = awaitItem() val initial = awaitItem()
initial.eventSink(ChooseSelfVerificationModeEvent.SignOut) initial.eventSink(ChooseSelfVerificationModeEvent.SignOut)
logoutEventRecorder.assertions().isCalledOnce() logoutEventRecorder.assertions().isCalledOnce()
.with(value(DirectLogoutEvents.Logout(ignoreSdkError = false))) .with(value(DirectLogoutEvent.Logout(ignoreSdkError = false)))
} }
} }
@@ -16,7 +16,7 @@ import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent
import io.element.android.libraries.push.api.battery.BatteryOptimizationState import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
@@ -31,8 +31,8 @@ internal fun BatteryOptimizationBanner(
description = stringResource(R.string.banner_battery_optimization_content_android), description = stringResource(R.string.banner_battery_optimization_content_android),
type = AnnouncementType.Actionable( type = AnnouncementType.Actionable(
actionText = stringResource(R.string.banner_battery_optimization_submit_android), actionText = stringResource(R.string.banner_battery_optimization_submit_android),
onActionClick = { state.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations) }, onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) },
onDismissClick = { state.eventSink(BatteryOptimizationEvents.Dismiss) }, onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) },
), ),
) )
} }
@@ -16,7 +16,7 @@ import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -31,8 +31,8 @@ fun FullScreenIntentPermissionBanner(
description = stringResource(R.string.full_screen_intent_banner_message), description = stringResource(R.string.full_screen_intent_banner_message),
type = AnnouncementType.Actionable( type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_continue), actionText = stringResource(CommonStrings.action_continue),
onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvents.Dismiss) }, onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) },
onActionClick = { state.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) }, onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) },
), ),
modifier = modifier.roomListBannerPadding(), modifier = modifier.roomListBannerPadding(),
) )
@@ -53,8 +53,6 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -80,7 +78,7 @@ import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGra
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.text.AdaptativeTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@@ -141,15 +139,21 @@ fun HomeTopBar(
else -> stringResource(selectedNavigationItem.labelRes) else -> stringResource(selectedNavigationItem.labelRes)
} }
} }
HomeNavigationBarItem.Spaces -> stringResource(selectedNavigationItem.labelRes) HomeNavigationBarItem.Spaces -> null
}
displayTitle?.let {
val style = when (spaceFiltersState) {
// Space name
is SpaceFiltersState.Selected -> ElementTheme.typography.fontHeadingSmMedium
// "Chats"
else -> ElementTheme.typography.fontHeadingLgBold
}
AdaptativeTitle(
title = displayTitle,
style = style,
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
)
} }
Text(
modifier = Modifier.semantics {
heading()
},
style = ElementTheme.typography.aliasScreenTitle,
text = displayTitle,
)
}, },
navigationIcon = { navigationIcon = {
NavigationIcon( NavigationIcon(
@@ -179,7 +183,9 @@ fun HomeTopBar(
TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) { TopAppBarScrollBehaviorLayout(scrollBehavior = scrollBehavior) {
RoomListFiltersView( RoomListFiltersView(
state = filtersState, state = filtersState,
modifier = Modifier.padding(bottom = 16.dp).padding(contentPadding) modifier = Modifier
.padding(bottom = 16.dp)
.padding(contentPadding)
) )
} }
} }
@@ -348,45 +354,45 @@ private fun AccountIcon(
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val avatarData by remember(matrixUser) { val avatarData by remember(matrixUser) {
derivedStateOf { derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
} }
val statusEmoji = matrixUser.displayedStatus?.toEmojiText() }
val avatarModifier = if (statusEmoji != null) { val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
Modifier.eraseStatusEmojiBackground( val avatarModifier = if (statusEmoji != null) {
parentSize = AvatarSize.CurrentUserTopBar.dp, Modifier.eraseStatusEmojiBackground(
layoutDirection = LocalLayoutDirection.current, parentSize = AvatarSize.CurrentUserTopBar.dp,
) layoutDirection = LocalLayoutDirection.current,
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
} else {
stringResource(CommonStrings.common_settings)
}
} else {
null
},
) )
if (statusEmoji != null) { } else {
StatusEmojiBadge( Modifier
emoji = statusEmoji, }
modifier = Modifier.align(Alignment.BottomEnd), Avatar(
) avatarData = avatarData,
} avatarType = AvatarType.User,
if (showAvatarIndicator) { modifier = avatarModifier,
RedIndicatorAtom( contentDescription = if (isCurrentAccount) {
modifier = Modifier.align(Alignment.TopEnd) if (showAvatarIndicator) {
) stringResource(CommonStrings.a11y_settings_with_required_action)
} } else {
stringResource(CommonStrings.common_settings)
}
} else {
null
},
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
} }
} }
@@ -19,6 +19,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.matrix.api.room.CallIntentConsensus import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.dmUserStatus import io.element.android.libraries.matrix.ui.model.dmUserStatus
@@ -47,7 +48,7 @@ class RoomListRoomSummaryFactory(
mode = DateFormatterMode.TimeOrDate, mode = DateFormatterMode.TimeOrDate,
useRelative = true, useRelative = true,
), ),
latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.isDm), latestEvent = computeLatestEvent(roomSummary.latestEvent, roomInfo.hasOnlyTwoMembers()),
avatarData = avatarData, avatarData = avatarData,
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode, userDefinedNotificationMode = roomInfo.userDefinedNotificationMode,
hasRoomCall = roomInfo.hasRoomCall, hasRoomCall = roomInfo.hasRoomCall,
@@ -81,6 +82,10 @@ class RoomListRoomSummaryFactory(
) )
} }
private fun RoomInfo.hasOnlyTwoMembers(): Boolean {
return isDm || activeMembersCount <= 2
}
private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent { private fun computeLatestEvent(latestEvent: LatestEventValue, dm: Boolean): LatestEvent {
return when (latestEvent) { return when (latestEvent) {
is LatestEventValue.None -> { is LatestEventValue.None -> {
@@ -37,8 +37,8 @@ import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.into import io.element.android.features.home.impl.spacefilters.into
import io.element.android.features.home.impl.spacefilters.selectedFilter import io.element.android.features.home.impl.spacefilters.selectedFilter
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent.AcceptInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent.DeclineInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
@@ -7,9 +7,6 @@
package io.element.android.features.home.impl.search package io.element.android.features.home.impl.search
import androidx.compose.runtime.Immutable
@Immutable
sealed interface GlobalSearchEvent { sealed interface GlobalSearchEvent {
object ClearQuery : GlobalSearchEvent object ClearQuery : GlobalSearchEvent
object ToggleSearchVisibility : GlobalSearchEvent object ToggleSearchVisibility : GlobalSearchEvent
@@ -8,4 +8,4 @@
package io.element.android.features.home.impl.spaces package io.element.android.features.home.impl.spaces
sealed interface HomeSpacesEvents sealed interface HomeSpacesEvent
@@ -39,7 +39,7 @@ class HomeSpacesPresenter(
seenInvitesStore.seenRoomIds().map { it.toImmutableSet() } seenInvitesStore.seenRoomIds().map { it.toImmutableSet() }
}.collectAsState(persistentSetOf()) }.collectAsState(persistentSetOf())
fun handleEvent(event: HomeSpacesEvents) { fun handleEvent(event: HomeSpacesEvent) {
// when (event) { } // when (event) { }
} }
@@ -19,7 +19,7 @@ data class HomeSpacesState(
val seenSpaceInvites: ImmutableSet<RoomId>, val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean, val hideInvitesAvatar: Boolean,
val canExploreSpaces: Boolean, val canExploreSpaces: Boolean,
val eventSink: (HomeSpacesEvents) -> Unit, val eventSink: (HomeSpacesEvent) -> Unit,
) )
sealed interface CurrentSpace { sealed interface CurrentSpace {
@@ -43,7 +43,7 @@ internal fun aHomeSpacesState(
seenSpaceInvites: Set<RoomId> = emptySet(), seenSpaceInvites: Set<RoomId> = emptySet(),
hideInvitesAvatar: Boolean = false, hideInvitesAvatar: Boolean = false,
canExploreSpaces: Boolean = true, canExploreSpaces: Boolean = true,
eventSink: (HomeSpacesEvents) -> Unit = {}, eventSink: (HomeSpacesEvent) -> Unit = {},
) = HomeSpacesState( ) = HomeSpacesState(
space = space, space = space,
spaceRooms = spaceRooms.toImmutableList(), spaceRooms = spaceRooms.toImmutableList(),
@@ -143,7 +143,7 @@ private fun EmptySpaceHomeView(
) )
Text( Text(
text = stringResource(CommonStrings.screen_space_list_empty_state_title), text = stringResource(CommonStrings.screen_space_list_empty_state_title),
style = ElementTheme.typography.fontHeadingLgBold, style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@@ -8,10 +8,36 @@
package io.element.android.features.home.impl.datasource package io.element.android.features.home.impl.datasource
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomSummary
import org.junit.Test
class RoomListRoomSummaryFactoryTest {
@Test
fun `a room with two members does not prefix its preview with the sender`() {
val formatter = FakeRoomLatestEventFormatter()
val factory = aRoomListRoomSummaryFactory(roomLatestEventFormatter = formatter)
factory.create(aRoomSummary(info = aRoomInfo(isDm = false, activeMembersCount = 2)))
assertThat(formatter.lastIsDmRoom).isTrue()
}
@Test
fun `a room with more than two members prefixes its preview with the sender`() {
val formatter = FakeRoomLatestEventFormatter()
val factory = aRoomListRoomSummaryFactory(roomLatestEventFormatter = formatter)
factory.create(aRoomSummary(info = aRoomInfo(isDm = false, activeMembersCount = 3)))
assertThat(formatter.lastIsDmRoom).isFalse()
}
}
fun aRoomListRoomSummaryFactory( fun aRoomListRoomSummaryFactory(
dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" }, dateFormatter: DateFormatter = FakeDateFormatter { _, _, _ -> "Today" },
@@ -25,7 +25,7 @@ import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState import io.element.android.features.home.impl.spacefilters.aDisabledSpaceFiltersState
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore
@@ -474,7 +474,7 @@ class RoomListPresenterTest {
@Test @Test
fun `present - when a room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest { fun `present - when a room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest {
val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> } val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvent -> }
val acceptDeclinePresenter = Presenter { val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder) anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
} }
@@ -513,8 +513,8 @@ class RoomListPresenterTest {
assert(eventSinkRecorder) assert(eventSinkRecorder)
.isCalledExactly(2) .isCalledExactly(2)
.withSequence( .withSequence(
listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))), listOf(value(AcceptDeclineInviteEvent.AcceptInvite(inviteData))),
listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false))), listOf(value(AcceptDeclineInviteEvent.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false))),
) )
} }
} }
@@ -13,7 +13,7 @@ import io.element.android.features.invite.api.InviteData
/** /**
* Events sent to the accept/decline invite presenter, from whichever screen is showing the invitation. * Events sent to the accept/decline invite presenter, from whichever screen is showing the invitation.
*/ */
interface AcceptDeclineInviteEvents { interface AcceptDeclineInviteEvent {
data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvent
data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvents data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvent
} }
@@ -14,5 +14,5 @@ import io.element.android.libraries.matrix.api.core.RoomId
data class AcceptDeclineInviteState( data class AcceptDeclineInviteState(
val acceptAction: AsyncAction<RoomId>, val acceptAction: AsyncAction<RoomId>,
val declineAction: AsyncAction<RoomId>, val declineAction: AsyncAction<RoomId>,
val eventSink: (AcceptDeclineInviteEvents) -> Unit, val eventSink: (AcceptDeclineInviteEvent) -> Unit,
) )
@@ -14,7 +14,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
fun anAcceptDeclineInviteState( fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {}, eventSink: (AcceptDeclineInviteEvent) -> Unit = {},
) = AcceptDeclineInviteState( ) = AcceptDeclineInviteState(
acceptAction = acceptAction, acceptAction = acceptAction,
declineAction = declineAction, declineAction = declineAction,
@@ -15,7 +15,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite
import io.element.android.features.invite.impl.AcceptInvite import io.element.android.features.invite.impl.AcceptInvite
@@ -40,13 +40,13 @@ class AcceptDeclineInvitePresenter(
val declinedAction: MutableState<AsyncAction<RoomId>> = val declinedAction: MutableState<AsyncAction<RoomId>> =
remember { mutableStateOf(AsyncAction.Uninitialized) } remember { mutableStateOf(AsyncAction.Uninitialized) }
fun handleEvent(event: AcceptDeclineInviteEvents) { fun handleEvent(event: AcceptDeclineInviteEvent) {
when (event) { when (event) {
is AcceptDeclineInviteEvents.AcceptInvite -> { is AcceptDeclineInviteEvent.AcceptInvite -> {
localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction)
} }
is AcceptDeclineInviteEvents.DeclineInvite -> { is AcceptDeclineInviteEvent.DeclineInvite -> {
val inviteData = event.invite val inviteData = event.invite
if (event.shouldConfirm) { if (event.shouldConfirm) {
declinedAction.value = ConfirmingDeclineInvite(inviteData, event.blockUser) declinedAction.value = ConfirmingDeclineInvite(inviteData, event.blockUser)
@@ -58,11 +58,11 @@ class AcceptDeclineInvitePresenter(
) )
} }
} }
is InternalAcceptDeclineInviteEvents.ClearAcceptActionState -> { is InternalAcceptDeclineInviteEvent.ClearAcceptActionState -> {
acceptedAction.value = AsyncAction.Uninitialized acceptedAction.value = AsyncAction.Uninitialized
} }
is InternalAcceptDeclineInviteEvents.ClearDeclineActionState -> { is InternalAcceptDeclineInviteEvent.ClearDeclineActionState -> {
declinedAction.value = AsyncAction.Uninitialized declinedAction.value = AsyncAction.Uninitialized
} }
} }
@@ -14,7 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite
import io.element.android.features.invite.impl.AcceptInvite import io.element.android.features.invite.impl.AcceptInvite
@@ -37,11 +37,11 @@ fun AcceptDeclineInviteView(
AsyncActionView( AsyncActionView(
async = state.acceptAction, async = state.acceptAction,
onSuccess = { roomId -> onSuccess = { roomId ->
state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState) state.eventSink(InternalAcceptDeclineInviteEvent.ClearAcceptActionState)
onAcceptInviteSuccess(roomId) onAcceptInviteSuccess(roomId)
}, },
onErrorDismiss = { onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.ClearAcceptActionState) state.eventSink(InternalAcceptDeclineInviteEvent.ClearAcceptActionState)
}, },
errorTitle = { errorTitle = {
stringResource(CommonStrings.common_something_went_wrong) stringResource(CommonStrings.common_something_went_wrong)
@@ -57,11 +57,11 @@ fun AcceptDeclineInviteView(
AsyncActionView( AsyncActionView(
async = state.declineAction, async = state.declineAction,
onSuccess = { roomId -> onSuccess = { roomId ->
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState) state.eventSink(InternalAcceptDeclineInviteEvent.ClearDeclineActionState)
onDeclineInviteSuccess(roomId) onDeclineInviteSuccess(roomId)
}, },
onErrorDismiss = { onErrorDismiss = {
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState) state.eventSink(InternalAcceptDeclineInviteEvent.ClearDeclineActionState)
}, },
errorTitle = { errorTitle = {
stringResource(CommonStrings.common_something_went_wrong) stringResource(CommonStrings.common_something_went_wrong)
@@ -77,7 +77,7 @@ fun AcceptDeclineInviteView(
blockUser = confirming.blockUser, blockUser = confirming.blockUser,
onConfirmClick = { onConfirmClick = {
state.eventSink( state.eventSink(
AcceptDeclineInviteEvents.DeclineInvite( AcceptDeclineInviteEvent.DeclineInvite(
confirming.inviteData, confirming.inviteData,
blockUser = confirming.blockUser, blockUser = confirming.blockUser,
shouldConfirm = false shouldConfirm = false
@@ -85,7 +85,7 @@ fun AcceptDeclineInviteView(
) )
}, },
onDismissClick = { onDismissClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.ClearDeclineActionState) state.eventSink(InternalAcceptDeclineInviteEvent.ClearDeclineActionState)
} }
) )
} }
@@ -8,9 +8,9 @@
package io.element.android.features.invite.impl.acceptdecline package io.element.android.features.invite.impl.acceptdecline
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent
sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents { sealed interface InternalAcceptDeclineInviteEvent : AcceptDeclineInviteEvent {
data object ClearAcceptActionState : InternalAcceptDeclineInviteEvents data object ClearAcceptActionState : InternalAcceptDeclineInviteEvent
data object ClearDeclineActionState : InternalAcceptDeclineInviteEvents data object ClearDeclineActionState : InternalAcceptDeclineInviteEvent
} }
@@ -8,10 +8,10 @@
package io.element.android.features.invite.impl.declineandblock package io.element.android.features.invite.impl.declineandblock
sealed interface DeclineAndBlockEvents { sealed interface DeclineAndBlockEvent {
data class UpdateReportReason(val reason: String) : DeclineAndBlockEvents data class UpdateReportReason(val reason: String) : DeclineAndBlockEvent
data object ToggleReportRoom : DeclineAndBlockEvents data object ToggleReportRoom : DeclineAndBlockEvent
data object ToggleBlockUser : DeclineAndBlockEvents data object ToggleBlockUser : DeclineAndBlockEvent
data object Decline : DeclineAndBlockEvents data object Decline : DeclineAndBlockEvent
data object ClearDeclineAction : DeclineAndBlockEvents data object ClearDeclineAction : DeclineAndBlockEvent
} }
@@ -49,13 +49,13 @@ class DeclineAndBlockPresenter(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
fun handleEvent(event: DeclineAndBlockEvents) { fun handleEvent(event: DeclineAndBlockEvent) {
when (event) { when (event) {
DeclineAndBlockEvents.ClearDeclineAction -> declineAction.value = AsyncAction.Uninitialized DeclineAndBlockEvent.ClearDeclineAction -> declineAction.value = AsyncAction.Uninitialized
DeclineAndBlockEvents.Decline -> coroutineScope.decline(reportReason, blockUser, reportRoom, declineAction) DeclineAndBlockEvent.Decline -> coroutineScope.decline(reportReason, blockUser, reportRoom, declineAction)
DeclineAndBlockEvents.ToggleBlockUser -> blockUser = !blockUser DeclineAndBlockEvent.ToggleBlockUser -> blockUser = !blockUser
DeclineAndBlockEvents.ToggleReportRoom -> reportRoom = !reportRoom DeclineAndBlockEvent.ToggleReportRoom -> reportRoom = !reportRoom
is DeclineAndBlockEvents.UpdateReportReason -> reportReason = event.reason is DeclineAndBlockEvent.UpdateReportReason -> reportReason = event.reason
} }
} }
@@ -15,7 +15,7 @@ data class DeclineAndBlockState(
val reportReason: String, val reportReason: String,
val blockUser: Boolean, val blockUser: Boolean,
val declineAction: AsyncAction<Unit>, val declineAction: AsyncAction<Unit>,
val eventSink: (DeclineAndBlockEvents) -> Unit val eventSink: (DeclineAndBlockEvent) -> Unit
) { ) {
val canDecline = blockUser || reportRoom && reportReason.isNotEmpty() val canDecline = blockUser || reportRoom && reportReason.isNotEmpty()
} }
@@ -36,7 +36,7 @@ fun aDeclineAndBlockState(
reportReason: String = "", reportReason: String = "",
blockUser: Boolean = false, blockUser: Boolean = false,
declineAction: AsyncAction<Unit> = AsyncAction.Uninitialized, declineAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (DeclineAndBlockEvents) -> Unit = {}, eventSink: (DeclineAndBlockEvent) -> Unit = {},
) = DeclineAndBlockState( ) = DeclineAndBlockState(
reportRoom = reportRoom, reportRoom = reportRoom,
reportReason = reportReason, reportReason = reportReason,
@@ -56,8 +56,8 @@ fun DeclineAndBlockView(
async = state.declineAction, async = state.declineAction,
onSuccess = { onDeclineSuccess() }, onSuccess = { onDeclineSuccess() },
errorMessage = { stringResource(CommonStrings.error_unknown) }, errorMessage = { stringResource(CommonStrings.error_unknown) },
onRetry = { state.eventSink(DeclineAndBlockEvents.Decline) }, onRetry = { state.eventSink(DeclineAndBlockEvent.Decline) },
onErrorDismiss = { state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) } onErrorDismiss = { state.eventSink(DeclineAndBlockEvent.ClearDeclineAction) }
) )
Scaffold( Scaffold(
@@ -89,7 +89,7 @@ fun DeclineAndBlockView(
Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_description)) Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_description))
}, },
onClick = { onClick = {
state.eventSink(DeclineAndBlockEvents.ToggleBlockUser) state.eventSink(DeclineAndBlockEvent.ToggleBlockUser)
}, },
trailingContent = ListItemContent.Switch(checked = state.blockUser) trailingContent = ListItemContent.Switch(checked = state.blockUser)
) )
@@ -104,7 +104,7 @@ fun DeclineAndBlockView(
Text(text = stringResource(R.string.screen_decline_and_block_report_user_option_description)) Text(text = stringResource(R.string.screen_decline_and_block_report_user_option_description))
}, },
onClick = { onClick = {
state.eventSink(DeclineAndBlockEvents.ToggleReportRoom) state.eventSink(DeclineAndBlockEvent.ToggleReportRoom)
}, },
trailingContent = ListItemContent.Switch(checked = state.reportRoom) trailingContent = ListItemContent.Switch(checked = state.reportRoom)
) )
@@ -113,7 +113,7 @@ fun DeclineAndBlockView(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
TextField( TextField(
value = state.reportReason, value = state.reportReason,
onValueChange = { state.eventSink(DeclineAndBlockEvents.UpdateReportReason(it)) }, onValueChange = { state.eventSink(DeclineAndBlockEvent.UpdateReportReason(it)) },
placeholder = stringResource(R.string.screen_decline_and_block_report_user_reason_placeholder), placeholder = stringResource(R.string.screen_decline_and_block_report_user_reason_placeholder),
minLines = 3, minLines = 3,
enabled = !isDeclining, enabled = !isDeclining,
@@ -132,7 +132,7 @@ fun DeclineAndBlockView(
enabled = !isDeclining && state.canDecline, enabled = !isDeclining && state.canDecline,
onClick = { onClick = {
focusManager.clearFocus(force = true) focusManager.clearFocus(force = true)
state.eventSink(DeclineAndBlockEvents.Decline) state.eventSink(DeclineAndBlockEvent.Decline)
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -10,7 +10,7 @@ package io.element.android.features.invite.impl.acceptdecline
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent
import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite
import io.element.android.features.invite.impl.AcceptInvite import io.element.android.features.invite.impl.AcceptInvite
import io.element.android.features.invite.impl.DeclineInvite import io.element.android.features.invite.impl.DeclineInvite
@@ -51,13 +51,13 @@ class AcceptDeclineInvitePresenterTest {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink( state.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true) AcceptDeclineInviteEvent.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true)
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ClearDeclineActionState InternalAcceptDeclineInviteEvent.ClearDeclineActionState
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
@@ -78,20 +78,20 @@ class AcceptDeclineInvitePresenterTest {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink( state.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true) AcceptDeclineInviteEvent.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true)
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, false))
state.eventSink( state.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false) AcceptDeclineInviteEvent.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false)
) )
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ClearDeclineActionState InternalAcceptDeclineInviteEvent.ClearDeclineActionState
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
@@ -114,13 +114,13 @@ class AcceptDeclineInvitePresenterTest {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink( state.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true) AcceptDeclineInviteEvent.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true)
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, blockUser = false)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData, blockUser = false))
state.eventSink( state.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false) AcceptDeclineInviteEvent.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false)
) )
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
@@ -146,7 +146,7 @@ class AcceptDeclineInvitePresenterTest {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink( state.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(inviteData) AcceptDeclineInviteEvent.AcceptInvite(inviteData)
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
@@ -155,7 +155,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ClearAcceptActionState InternalAcceptDeclineInviteEvent.ClearAcceptActionState
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
@@ -178,7 +178,7 @@ class AcceptDeclineInvitePresenterTest {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink( state.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(inviteData) AcceptDeclineInviteEvent.AcceptInvite(inviteData)
) )
} }
awaitItem().also { state -> awaitItem().also { state ->
@@ -53,21 +53,21 @@ class DeclineAndBlockPresenterTest {
assertThat(state.blockUser).isTrue() assertThat(state.blockUser).isTrue()
assertThat(state.reportReason).isEmpty() assertThat(state.reportReason).isEmpty()
assertThat(state.canDecline).isTrue() assertThat(state.canDecline).isTrue()
state.eventSink(DeclineAndBlockEvents.ToggleBlockUser) state.eventSink(DeclineAndBlockEvent.ToggleBlockUser)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.reportRoom).isFalse() assertThat(state.reportRoom).isFalse()
assertThat(state.blockUser).isFalse() assertThat(state.blockUser).isFalse()
assertThat(state.reportReason).isEmpty() assertThat(state.reportReason).isEmpty()
assertThat(state.canDecline).isFalse() assertThat(state.canDecline).isFalse()
state.eventSink(DeclineAndBlockEvents.ToggleReportRoom) state.eventSink(DeclineAndBlockEvent.ToggleReportRoom)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.reportRoom).isTrue() assertThat(state.reportRoom).isTrue()
assertThat(state.blockUser).isFalse() assertThat(state.blockUser).isFalse()
assertThat(state.reportReason).isEmpty() assertThat(state.reportReason).isEmpty()
assertThat(state.canDecline).isFalse() assertThat(state.canDecline).isFalse()
state.eventSink(DeclineAndBlockEvents.UpdateReportReason("Spam")) state.eventSink(DeclineAndBlockEvent.UpdateReportReason("Spam"))
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.reportRoom).isTrue() assertThat(state.reportRoom).isTrue()
@@ -87,7 +87,7 @@ class DeclineAndBlockPresenterTest {
) )
presenter.test { presenter.test {
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(DeclineAndBlockEvents.Decline) state.eventSink(DeclineAndBlockEvent.Decline)
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state -> awaitItem().also { state ->
@@ -110,12 +110,12 @@ class DeclineAndBlockPresenterTest {
) )
presenter.test { presenter.test {
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(DeclineAndBlockEvents.Decline) state.eventSink(DeclineAndBlockEvent.Decline)
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Failure::class.java)
state.eventSink(DeclineAndBlockEvents.ClearDeclineAction) state.eventSink(DeclineAndBlockEvent.ClearDeclineAction)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
@@ -137,7 +137,7 @@ class DeclineAndBlockPresenterTest {
) )
presenter.test { presenter.test {
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(DeclineAndBlockEvents.Decline) state.eventSink(DeclineAndBlockEvent.Decline)
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
awaitItem().also { state -> awaitItem().also { state ->
@@ -30,7 +30,7 @@ import org.junit.Test
class DeclineAndBlockViewTest : RobolectricTest() { class DeclineAndBlockViewTest : RobolectricTest() {
@Test @Test
fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest { fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeclineAndBlockEvent>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
@@ -44,7 +44,7 @@ class DeclineAndBlockViewTest : RobolectricTest() {
@Test @Test
fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest { fun `a successful decline invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeclineAndBlockEvent>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
@@ -58,7 +58,7 @@ class DeclineAndBlockViewTest : RobolectricTest() {
@Test @Test
fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest { fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>() val eventsRecorder = EventsRecorder<DeclineAndBlockEvent>()
setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
blockUser = true, blockUser = true,
@@ -66,12 +66,12 @@ class DeclineAndBlockViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_decline) clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(DeclineAndBlockEvents.Decline) eventsRecorder.assertSingle(DeclineAndBlockEvent.Decline)
} }
@Test @Test
fun `clicking on decline when disabled does not emit event`() = runAndroidComposeUiTest { fun `clicking on decline when disabled does not emit event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeclineAndBlockEvent>(expectEvents = false)
setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
blockUser = false, blockUser = false,
@@ -85,7 +85,7 @@ class DeclineAndBlockViewTest : RobolectricTest() {
@Test @Test
fun `clicking on block option emits the expected event`() = runAndroidComposeUiTest { fun `clicking on block option emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>() val eventsRecorder = EventsRecorder<DeclineAndBlockEvent>()
setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
blockUser = true, blockUser = true,
@@ -93,12 +93,12 @@ class DeclineAndBlockViewTest : RobolectricTest() {
), ),
) )
clickOn(R.string.screen_decline_and_block_block_user_option_title) clickOn(R.string.screen_decline_and_block_block_user_option_title)
eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleBlockUser) eventsRecorder.assertSingle(DeclineAndBlockEvent.ToggleBlockUser)
} }
@Test @Test
fun `clicking on report room option emits the expected event`() = runAndroidComposeUiTest { fun `clicking on report room option emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>() val eventsRecorder = EventsRecorder<DeclineAndBlockEvent>()
setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
reportRoom = true, reportRoom = true,
@@ -106,12 +106,12 @@ class DeclineAndBlockViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_report_room) clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleReportRoom) eventsRecorder.assertSingle(DeclineAndBlockEvent.ToggleReportRoom)
} }
@Test @Test
fun `typing text in the reason field emits the expected Event`() = runAndroidComposeUiTest { fun `typing text in the reason field emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>() val eventsRecorder = EventsRecorder<DeclineAndBlockEvent>()
setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
reportRoom = true, reportRoom = true,
@@ -120,7 +120,7 @@ class DeclineAndBlockViewTest : RobolectricTest() {
), ),
) )
onNodeWithText("").performTextInput("Spam!") onNodeWithText("").performTextInput("Spam!")
eventsRecorder.assertSingle(DeclineAndBlockEvents.UpdateReportReason("Spam!")) eventsRecorder.assertSingle(DeclineAndBlockEvent.UpdateReportReason("Spam!"))
} }
} }
@@ -11,8 +11,8 @@ package io.element.android.features.invitepeople.api
/** /**
* Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink]. * Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink].
*/ */
interface InvitePeopleEvents { interface InvitePeopleEvent {
data object SendInvites : InvitePeopleEvents data object SendInvites : InvitePeopleEvent
data object CloseSearch : InvitePeopleEvents data object CloseSearch : InvitePeopleEvent
data object ClearError : InvitePeopleEvents data object ClearError : InvitePeopleEvent
} }
@@ -28,5 +28,5 @@ interface InvitePeopleState {
val createRoomFromDmAction: AsyncAction<RoomId> val createRoomFromDmAction: AsyncAction<RoomId>
/** Where the UI sends its events. */ /** Where the UI sends its events. */
val eventSink: (InvitePeopleEvents) -> Unit val eventSink: (InvitePeopleEvent) -> Unit
} }
@@ -27,7 +27,7 @@ private data class PreviewInvitePeopleState(
override val isSearchActive: Boolean, override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>, override val sendInvitesAction: AsyncAction<Unit>,
override val createRoomFromDmAction: AsyncAction<RoomId>, override val createRoomFromDmAction: AsyncAction<RoomId>,
override val eventSink: (InvitePeopleEvents) -> Unit, override val eventSink: (InvitePeopleEvent) -> Unit,
) : InvitePeopleState ) : InvitePeopleState
private fun aPreviewInvitePeopleState( private fun aPreviewInvitePeopleState(
@@ -35,7 +35,7 @@ private fun aPreviewInvitePeopleState(
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized, sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized, createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (InvitePeopleEvents) -> Unit = {}, eventSink: (InvitePeopleEvent) -> Unit = {},
) = PreviewInvitePeopleState( ) = PreviewInvitePeopleState(
canInvite = canInvite, canInvite = canInvite,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
@@ -8,12 +8,12 @@
package io.element.android.features.invitepeople.impl package io.element.android.features.invitepeople.impl
import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.features.invitepeople.api.InvitePeopleEvent
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents { sealed interface DefaultInvitePeopleEvent : InvitePeopleEvent {
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvent
data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvent
data object DismissUnknownUsersModal : DefaultInvitePeopleEvents data object DismissUnknownUsersModal : DefaultInvitePeopleEvent
data object RemoveUnknownUsers : DefaultInvitePeopleEvents data object RemoveUnknownUsers : DefaultInvitePeopleEvent
} }
@@ -24,7 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.features.invitepeople.api.InvitePeopleEvent
import io.element.android.features.invitepeople.api.InvitePeoplePresenter import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleState import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@@ -179,26 +179,26 @@ class DefaultInvitePeoplePresenter(
) )
} }
fun handleEvent(event: InvitePeopleEvents) { fun handleEvent(event: InvitePeopleEvent) {
when (event) { when (event) {
// Dedicated `when` for exhaustivity. // Dedicated `when` for exhaustivity.
is DefaultInvitePeopleEvents -> when (event) { is DefaultInvitePeopleEvent -> when (event) {
is DefaultInvitePeopleEvents.OnSearchActiveChanged -> { is DefaultInvitePeopleEvent.OnSearchActiveChanged -> {
searchActive = event.active searchActive = event.active
if (!event.active) { if (!event.active) {
queryState.clearText() queryState.clearText()
} }
} }
is DefaultInvitePeopleEvents.ToggleUser -> { is DefaultInvitePeopleEvent.ToggleUser -> {
selectedUsers.toggleUser(event.user) selectedUsers.toggleUser(event.user)
searchResults.toggleUser(event.user) searchResults.toggleUser(event.user)
// suggestions will automatically update via derivedStateOf when selectedUsers changes // suggestions will automatically update via derivedStateOf when selectedUsers changes
} }
is DefaultInvitePeopleEvents.DismissUnknownUsersModal -> { is DefaultInvitePeopleEvent.DismissUnknownUsersModal -> {
sendInvitesAction.value = AsyncAction.Uninitialized sendInvitesAction.value = AsyncAction.Uninitialized
} }
is DefaultInvitePeopleEvents.RemoveUnknownUsers -> { is DefaultInvitePeopleEvent.RemoveUnknownUsers -> {
val usersToRemove = selectedUsers.value.filter { it in unknownUsers } val usersToRemove = selectedUsers.value.filter { it in unknownUsers }
usersToRemove.forEach { user -> usersToRemove.forEach { user ->
selectedUsers.toggleUser(user) selectedUsers.toggleUser(user)
@@ -207,7 +207,7 @@ class DefaultInvitePeoplePresenter(
sendInvitesAction.value = AsyncAction.Uninitialized sendInvitesAction.value = AsyncAction.Uninitialized
} }
} }
is InvitePeopleEvents.SendInvites -> { is InvitePeopleEvent.SendInvites -> {
if (unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) { if (unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) {
sendInvitesAction.value = ConfirmingUnknownUserInvitation( sendInvitesAction.value = ConfirmingUnknownUserInvitation(
unknownUsers unknownUsers
@@ -224,11 +224,11 @@ class DefaultInvitePeoplePresenter(
} }
} }
} }
is InvitePeopleEvents.CloseSearch -> { is InvitePeopleEvent.CloseSearch -> {
searchActive = false searchActive = false
queryState.clearText() queryState.clearText()
} }
is InvitePeopleEvents.ClearError -> { is InvitePeopleEvent.ClearError -> {
sendInvitesAction.value = AsyncAction.Uninitialized sendInvitesAction.value = AsyncAction.Uninitialized
createRoomFromDmAction.value = AsyncAction.Uninitialized createRoomFromDmAction.value = AsyncAction.Uninitialized
} }
@@ -9,7 +9,7 @@
package io.element.android.features.invitepeople.impl package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.features.invitepeople.api.InvitePeopleEvent
import io.element.android.features.invitepeople.api.InvitePeopleState import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@@ -29,5 +29,5 @@ data class DefaultInvitePeopleState(
override val sendInvitesAction: AsyncAction<Unit>, override val sendInvitesAction: AsyncAction<Unit>,
override val createRoomFromDmAction: AsyncAction<RoomId>, override val createRoomFromDmAction: AsyncAction<RoomId>,
val suggestions: ImmutableList<InvitableUser>, val suggestions: ImmutableList<InvitableUser>,
override val eventSink: (InvitePeopleEvents) -> Unit override val eventSink: (InvitePeopleEvent) -> Unit
) : InvitePeopleState ) : InvitePeopleState
@@ -30,7 +30,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.features.invitepeople.api.InvitePeopleEvent
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
@@ -102,7 +102,7 @@ private fun InvitePeopleContentView(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
fun toggleUser(user: MatrixUser) { fun toggleUser(user: MatrixUser) {
state.eventSink(DefaultInvitePeopleEvents.ToggleUser(user)) state.eventSink(DefaultInvitePeopleEvent.ToggleUser(user))
} }
InvitePeopleSearchBar( InvitePeopleSearchBar(
@@ -114,7 +114,7 @@ private fun InvitePeopleContentView(
active = state.isSearchActive, active = state.isSearchActive,
onActiveChange = { onActiveChange = {
state.eventSink( state.eventSink(
DefaultInvitePeopleEvents.OnSearchActiveChanged( DefaultInvitePeopleEvent.OnSearchActiveChanged(
it it
) )
) )
@@ -144,7 +144,7 @@ private fun InvitePeopleContentView(
CheckableUserRow( CheckableUserRow(
checked = invitableUser.isSelected, checked = invitableUser.isSelected,
onCheckedChange = { onCheckedChange = {
state.eventSink(DefaultInvitePeopleEvents.ToggleUser(invitableUser.matrixUser)) state.eventSink(DefaultInvitePeopleEvent.ToggleUser(invitableUser.matrixUser))
}, },
data = CheckableUserRowData.Resolved( data = CheckableUserRowData.Resolved(
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem), avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.UserListItem),
@@ -163,9 +163,9 @@ private fun InvitePeopleContentView(
if (state.sendInvitesAction is ConfirmingUnknownUserInvitation) { if (state.sendInvitesAction is ConfirmingUnknownUserInvitation) {
InvitePeopleConfirmModal( InvitePeopleConfirmModal(
users = state.sendInvitesAction.users, users = state.sendInvitesAction.users,
onDismiss = { state.eventSink.invoke(DefaultInvitePeopleEvents.DismissUnknownUsersModal) }, onDismiss = { state.eventSink.invoke(DefaultInvitePeopleEvent.DismissUnknownUsersModal) },
onInvite = { state.eventSink.invoke(InvitePeopleEvents.SendInvites) }, onInvite = { state.eventSink.invoke(InvitePeopleEvent.SendInvites) },
onRemove = { state.eventSink.invoke(DefaultInvitePeopleEvents.RemoveUnknownUsers) } onRemove = { state.eventSink.invoke(DefaultInvitePeopleEvent.RemoveUnknownUsers) }
) )
} }
} }
@@ -11,7 +11,7 @@ package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import app.cash.turbine.ReceiveTurbine import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.invitepeople.api.InvitePeopleEvents import io.element.android.features.invitepeople.api.InvitePeopleEvent
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@@ -88,13 +88,13 @@ internal class DefaultInvitePeoplePresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1) skipItems(1)
initialState.eventSink(DefaultInvitePeopleEvents.OnSearchActiveChanged(true)) initialState.eventSink(DefaultInvitePeopleEvent.OnSearchActiveChanged(true))
val resultState = awaitItemAsDefault() val resultState = awaitItemAsDefault()
assertThat(resultState.isSearchActive).isTrue() assertThat(resultState.isSearchActive).isTrue()
resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query") resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query") assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
resultState.eventSink(InvitePeopleEvents.CloseSearch) resultState.eventSink(InvitePeopleEvent.CloseSearch)
skipItems(1) skipItems(1)
awaitItemAsDefault().also { awaitItemAsDefault().also {
assertThat(it.isSearchActive).isFalse() assertThat(it.isSearchActive).isFalse()
@@ -290,18 +290,18 @@ internal class DefaultInvitePeoplePresenterTest {
skipItems(1) skipItems(1)
// When we toggle a user not in the list, they are added // When we toggle a user not in the list, they are added
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(aMatrixUser())) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(aMatrixUser()))
assertThat(awaitItemAsDefault().selectedUsers).containsExactly(aMatrixUser()) assertThat(awaitItemAsDefault().selectedUsers).containsExactly(aMatrixUser())
// Toggling a different user also adds them // Toggling a different user also adds them
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(aMatrixUser(id = A_USER_ID_2.value))) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(aMatrixUser(id = A_USER_ID_2.value)))
assertThat(awaitItemAsDefault().selectedUsers).containsExactly( assertThat(awaitItemAsDefault().selectedUsers).containsExactly(
aMatrixUser(), aMatrixUser(),
aMatrixUser(id = A_USER_ID_2.value) aMatrixUser(id = A_USER_ID_2.value)
) )
// Toggling the first user removes them // Toggling the first user removes them
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(aMatrixUser())) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(aMatrixUser()))
assertThat(awaitItemAsDefault().selectedUsers).containsExactly(aMatrixUser(id = A_USER_ID_2.value)) assertThat(awaitItemAsDefault().selectedUsers).containsExactly(aMatrixUser(id = A_USER_ID_2.value))
} }
} }
@@ -319,7 +319,7 @@ internal class DefaultInvitePeoplePresenterTest {
val selectedUser = aMatrixUser(displayName = "John Doe") val selectedUser = aMatrixUser(displayName = "John Doe")
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(selectedUser))
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query") initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
skipItems(1) skipItems(1)
@@ -373,7 +373,7 @@ internal class DefaultInvitePeoplePresenterTest {
} }
// And then a user is toggled // And then a user is toggled
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(selectedUser))
skipItems(1) skipItems(1)
val resultState = awaitItemAsDefault() val resultState = awaitItemAsDefault()
@@ -414,13 +414,13 @@ internal class DefaultInvitePeoplePresenterTest {
repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser) repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser)
skipItems(1) skipItems(1)
// And then a user is toggled // And then a user is toggled
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(selectedUser))
skipItems(1) skipItems(1)
val resultState = awaitItemAsDefault() val resultState = awaitItemAsDefault()
// The results are updated... // The results are updated...
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
// Send invites // Send invites
initialState.eventSink(InvitePeopleEvents.SendInvites) initialState.eventSink(InvitePeopleEvent.SendInvites)
// Can't invite in the loading state // Can't invite in the loading state
awaitItem().run { awaitItem().run {
@@ -468,13 +468,13 @@ internal class DefaultInvitePeoplePresenterTest {
repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser) repository.emitStateWithUsers(users = aMatrixUserList() + selectedUser)
skipItems(1) skipItems(1)
// And then a user is toggled // And then a user is toggled
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser)) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(selectedUser))
skipItems(1) skipItems(1)
val resultState = awaitItemAsDefault() val resultState = awaitItemAsDefault()
// The results are updated... // The results are updated...
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
// Send invites // Send invites
initialState.eventSink(InvitePeopleEvents.SendInvites) initialState.eventSink(InvitePeopleEvent.SendInvites)
// Can't invite in the loading state // Can't invite in the loading state
awaitItem().run { awaitItem().run {
@@ -645,17 +645,17 @@ internal class DefaultInvitePeoplePresenterTest {
skipItems(1) skipItems(1)
// When we toggle a user not in the list, they are added, and we fetch their identity. // When we toggle a user not in the list, they are added, and we fetch their identity.
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice)) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(alice))
delay(100) delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob)) awaitItemAsDefault().eventSink(DefaultInvitePeopleEvent.ToggleUser(bob))
delay(100) delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie)) awaitItemAsDefault().eventSink(DefaultInvitePeopleEvent.ToggleUser(charlie))
delay(100) delay(100)
// If we do not have their identity cached, or fail to fetch it, we should mark them as unknown. // If we do not have their identity cached, or fail to fetch it, we should mark them as unknown.
awaitItemAsDefault().run { awaitItemAsDefault().run {
assertThat(selectedUsers).containsExactly(alice, bob, charlie) assertThat(selectedUsers).containsExactly(alice, bob, charlie)
eventSink(InvitePeopleEvents.SendInvites) eventSink(InvitePeopleEvent.SendInvites)
} }
getUserIdentityResult.assertions().isCalledExactly(3).withSequence( getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
@@ -668,7 +668,7 @@ internal class DefaultInvitePeoplePresenterTest {
awaitItemAsDefault().run { awaitItemAsDefault().run {
assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java) assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java)
assertThat(canInvite).isTrue() assertThat(canInvite).isTrue()
eventSink(InvitePeopleEvents.SendInvites) eventSink(InvitePeopleEvent.SendInvites)
} }
delay(1_000) delay(1_000)
@@ -712,11 +712,11 @@ internal class DefaultInvitePeoplePresenterTest {
skipItems(1) skipItems(1)
// When we toggle a user not in the list, they are added, and we fetch their identity. // When we toggle a user not in the list, they are added, and we fetch their identity.
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice)) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(alice))
delay(100) delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob)) awaitItemAsDefault().eventSink(DefaultInvitePeopleEvent.ToggleUser(bob))
delay(100) delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie)) awaitItemAsDefault().eventSink(DefaultInvitePeopleEvent.ToggleUser(charlie))
delay(100) delay(100)
// And the search is matching Alice and Bob // And the search is matching Alice and Bob
@@ -738,7 +738,7 @@ internal class DefaultInvitePeoplePresenterTest {
searchResults.users().map { Pair(it.matrixUser, it.isSelected) } searchResults.users().map { Pair(it.matrixUser, it.isSelected) }
).containsExactly(Pair(alice, true), Pair(bob, true)) ).containsExactly(Pair(alice, true), Pair(bob, true))
eventSink(InvitePeopleEvents.SendInvites) eventSink(InvitePeopleEvent.SendInvites)
} }
getUserIdentityResult.assertions().isCalledExactly(3).withSequence( getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
@@ -751,7 +751,7 @@ internal class DefaultInvitePeoplePresenterTest {
awaitItemAsDefault().run { awaitItemAsDefault().run {
assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java) assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java)
assertThat(canInvite).isTrue() assertThat(canInvite).isTrue()
eventSink(DefaultInvitePeopleEvents.RemoveUnknownUsers) eventSink(DefaultInvitePeopleEvent.RemoveUnknownUsers)
} }
// Selecting "remove" should remove all unknown users, but keeps those who are known. // Selecting "remove" should remove all unknown users, but keeps those who are known.
@@ -794,16 +794,16 @@ internal class DefaultInvitePeoplePresenterTest {
skipItems(1) skipItems(1)
// When we toggle a user not in the list, they are added, and we fetch their identity. // When we toggle a user not in the list, they are added, and we fetch their identity.
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice)) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(alice))
delay(100) delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(bob)) awaitItemAsDefault().eventSink(DefaultInvitePeopleEvent.ToggleUser(bob))
delay(100) delay(100)
awaitItemAsDefault().eventSink(DefaultInvitePeopleEvents.ToggleUser(charlie)) awaitItemAsDefault().eventSink(DefaultInvitePeopleEvent.ToggleUser(charlie))
delay(100) delay(100)
awaitItemAsDefault().run { awaitItemAsDefault().run {
assertThat(selectedUsers).containsExactly(alice, bob, charlie) assertThat(selectedUsers).containsExactly(alice, bob, charlie)
eventSink(InvitePeopleEvents.SendInvites) eventSink(InvitePeopleEvent.SendInvites)
} }
getUserIdentityResult.assertions().isCalledExactly(3).withSequence( getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
@@ -816,7 +816,7 @@ internal class DefaultInvitePeoplePresenterTest {
awaitItemAsDefault().run { awaitItemAsDefault().run {
assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java) assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java)
assertThat(canInvite).isTrue() assertThat(canInvite).isTrue()
eventSink(DefaultInvitePeopleEvents.DismissUnknownUsersModal) eventSink(DefaultInvitePeopleEvent.DismissUnknownUsersModal)
} }
// Dismissing should not modify the selection at all // Dismissing should not modify the selection at all
@@ -853,10 +853,10 @@ internal class DefaultInvitePeoplePresenterTest {
skipItems(1) skipItems(1)
// We want to add a new user to a DM // We want to add a new user to a DM
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice)) initialState.eventSink(DefaultInvitePeopleEvent.ToggleUser(alice))
// And we send the invites // And we send the invites
initialState.eventSink(InvitePeopleEvents.SendInvites) initialState.eventSink(InvitePeopleEvent.SendInvites)
skipItems(1) skipItems(1)
@@ -10,15 +10,15 @@ package io.element.android.features.joinroom.impl
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
sealed interface JoinRoomEvents { sealed interface JoinRoomEvent {
data object RetryFetchingContent : JoinRoomEvents data object RetryFetchingContent : JoinRoomEvent
data object DismissErrorAndHideContent : JoinRoomEvents data object DismissErrorAndHideContent : JoinRoomEvent
data object JoinRoom : JoinRoomEvents data object JoinRoom : JoinRoomEvent
data object KnockRoom : JoinRoomEvents data object KnockRoom : JoinRoomEvent
data object ForgetRoom : JoinRoomEvents data object ForgetRoom : JoinRoomEvent
data class CancelKnock(val requiresConfirmation: Boolean) : JoinRoomEvents data class CancelKnock(val requiresConfirmation: Boolean) : JoinRoomEvent
data class UpdateKnockMessage(val message: String) : JoinRoomEvents data class UpdateKnockMessage(val message: String) : JoinRoomEvent
data object ClearActionStates : JoinRoomEvents data object ClearActionStates : JoinRoomEvent
data class AcceptInvite(val inviteData: InviteData) : JoinRoomEvents data class AcceptInvite(val inviteData: InviteData) : JoinRoomEvent
data class DeclineInvite(val inviteData: InviteData, val blockUser: Boolean) : JoinRoomEvents data class DeclineInvite(val inviteData: InviteData, val blockUser: Boolean) : JoinRoomEvent
} }
@@ -26,7 +26,7 @@ import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData import io.element.android.features.invite.api.toInviteData
import io.element.android.features.joinroom.impl.di.CancelKnockRoom import io.element.android.features.joinroom.impl.di.CancelKnockRoom
@@ -151,37 +151,37 @@ class JoinRoomPresenter(
contentState.markRoomInviteAsSeen() contentState.markRoomInviteAsSeen()
} }
fun handleEvent(event: JoinRoomEvents) { fun handleEvent(event: JoinRoomEvent) {
when (event) { when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction) JoinRoomEvent.JoinRoom -> coroutineScope.joinRoom(joinAction)
is JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage) is JoinRoomEvent.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage)
is JoinRoomEvents.AcceptInvite -> { is JoinRoomEvent.AcceptInvite -> {
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(event.inviteData) AcceptDeclineInviteEvent.AcceptInvite(event.inviteData)
) )
} }
is JoinRoomEvents.DeclineInvite -> { is JoinRoomEvent.DeclineInvite -> {
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(invite = event.inviteData, blockUser = event.blockUser, shouldConfirm = true) AcceptDeclineInviteEvent.DeclineInvite(invite = event.inviteData, blockUser = event.blockUser, shouldConfirm = true)
) )
} }
is JoinRoomEvents.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction) is JoinRoomEvent.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction)
JoinRoomEvents.RetryFetchingContent -> { JoinRoomEvent.RetryFetchingContent -> {
retryCount++ retryCount++
} }
JoinRoomEvents.ClearActionStates -> { JoinRoomEvent.ClearActionStates -> {
knockAction.value = AsyncAction.Uninitialized knockAction.value = AsyncAction.Uninitialized
joinAction.value = AsyncAction.Uninitialized joinAction.value = AsyncAction.Uninitialized
cancelKnockAction.value = AsyncAction.Uninitialized cancelKnockAction.value = AsyncAction.Uninitialized
forgetRoomAction.value = AsyncAction.Uninitialized forgetRoomAction.value = AsyncAction.Uninitialized
} }
is JoinRoomEvents.UpdateKnockMessage -> { is JoinRoomEvent.UpdateKnockMessage -> {
knockMessage = event.message.take(MAX_KNOCK_MESSAGE_LENGTH) knockMessage = event.message.take(MAX_KNOCK_MESSAGE_LENGTH)
} }
JoinRoomEvents.DismissErrorAndHideContent -> { JoinRoomEvent.DismissErrorAndHideContent -> {
isDismissingContent = true isDismissingContent = true
} }
JoinRoomEvents.ForgetRoom -> coroutineScope.forgetRoom(forgetRoomAction) JoinRoomEvent.ForgetRoom -> coroutineScope.forgetRoom(forgetRoomAction)
} }
} }
@@ -37,7 +37,7 @@ data class JoinRoomState(
val knockMessage: String, val knockMessage: String,
val hideInviteAvatars: Boolean, val hideInviteAvatars: Boolean,
val canReportRoom: Boolean, val canReportRoom: Boolean,
val eventSink: (JoinRoomEvents) -> Unit val eventSink: (JoinRoomEvent) -> Unit
) { ) {
val isJoinActionUnauthorized = joinAction is AsyncAction.Failure && joinAction.error is JoinRoom.Failures.UnauthorizedJoin val isJoinActionUnauthorized = joinAction is AsyncAction.Failure && joinAction.error is JoinRoom.Failures.UnauthorizedJoin
val joinAuthorisationStatus = when (contentState) { val joinAuthorisationStatus = when (contentState) {
@@ -207,7 +207,7 @@ fun aJoinRoomState(
knockMessage: String = "", knockMessage: String = "",
hideInviteAvatars: Boolean = false, hideInviteAvatars: Boolean = false,
canReportRoom: Boolean = true, canReportRoom: Boolean = true,
eventSink: (JoinRoomEvents) -> Unit = {} eventSink: (JoinRoomEvent) -> Unit = {}
) = JoinRoomState( ) = JoinRoomState(
roomIdOrAlias = roomIdOrAlias, roomIdOrAlias = roomIdOrAlias,
contentState = contentState, contentState = contentState,
@@ -115,33 +115,33 @@ fun JoinRoomView(
contentState = state.contentState, contentState = state.contentState,
knockMessage = state.knockMessage, knockMessage = state.knockMessage,
hideAvatarsImages = state.hideAvatarsImages, hideAvatarsImages = state.hideAvatarsImages,
onKnockMessageUpdate = { state.eventSink(JoinRoomEvents.UpdateKnockMessage(it)) }, onKnockMessageUpdate = { state.eventSink(JoinRoomEvent.UpdateKnockMessage(it)) },
) )
}, },
footer = { footer = {
JoinRoomFooter( JoinRoomFooter(
joinAuthorisationStatus = state.joinAuthorisationStatus, joinAuthorisationStatus = state.joinAuthorisationStatus,
onAcceptInvite = { inviteData -> onAcceptInvite = { inviteData ->
state.eventSink(JoinRoomEvents.AcceptInvite(inviteData)) state.eventSink(JoinRoomEvent.AcceptInvite(inviteData))
}, },
onDeclineInvite = { inviteData, blockUser -> onDeclineInvite = { inviteData, blockUser ->
if (state.canReportRoom && blockUser) { if (state.canReportRoom && blockUser) {
onDeclineInviteAndBlockUser(inviteData) onDeclineInviteAndBlockUser(inviteData)
} else { } else {
state.eventSink(JoinRoomEvents.DeclineInvite(inviteData, blockUser = blockUser)) state.eventSink(JoinRoomEvent.DeclineInvite(inviteData, blockUser = blockUser))
} }
}, },
onJoinRoom = { onJoinRoom = {
state.eventSink(JoinRoomEvents.JoinRoom) state.eventSink(JoinRoomEvent.JoinRoom)
}, },
onKnockRoom = { onKnockRoom = {
state.eventSink(JoinRoomEvents.KnockRoom) state.eventSink(JoinRoomEvent.KnockRoom)
}, },
onCancelKnock = { onCancelKnock = {
state.eventSink(JoinRoomEvents.CancelKnock(requiresConfirmation = true)) state.eventSink(JoinRoomEvent.CancelKnock(requiresConfirmation = true))
}, },
onForgetRoom = { onForgetRoom = {
state.eventSink(JoinRoomEvents.ForgetRoom) state.eventSink(JoinRoomEvent.ForgetRoom)
}, },
onGoBack = onBackClick, onGoBack = onBackClick,
) )
@@ -152,9 +152,9 @@ fun JoinRoomView(
RetryDialog( RetryDialog(
title = stringResource(R.string.screen_join_room_loading_alert_title), title = stringResource(R.string.screen_join_room_loading_alert_title),
content = stringResource(CommonStrings.error_network_or_server_issue), content = stringResource(CommonStrings.error_network_or_server_issue),
onRetry = { state.eventSink(JoinRoomEvents.RetryFetchingContent) }, onRetry = { state.eventSink(JoinRoomEvent.RetryFetchingContent) },
onDismiss = { onDismiss = {
state.eventSink(JoinRoomEvents.DismissErrorAndHideContent) state.eventSink(JoinRoomEvent.DismissErrorAndHideContent)
onBackClick() onBackClick()
} }
) )
@@ -166,7 +166,7 @@ fun JoinRoomView(
errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, errorTitle = { stringResource(CommonStrings.common_something_went_wrong) },
errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) },
onSuccess = { onJoinSuccess() }, onSuccess = { onJoinSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, onErrorDismiss = { state.eventSink(JoinRoomEvent.ClearActionStates) },
) )
} }
AsyncActionView( AsyncActionView(
@@ -174,19 +174,19 @@ fun JoinRoomView(
errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, errorTitle = { stringResource(CommonStrings.common_something_went_wrong) },
errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) },
onSuccess = { onKnockSuccess() }, onSuccess = { onKnockSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, onErrorDismiss = { state.eventSink(JoinRoomEvent.ClearActionStates) },
) )
AsyncActionView( AsyncActionView(
async = state.forgetAction, async = state.forgetAction,
errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, errorTitle = { stringResource(CommonStrings.common_something_went_wrong) },
errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) },
onSuccess = { onForgetSuccess() }, onSuccess = { onForgetSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, onErrorDismiss = { state.eventSink(JoinRoomEvent.ClearActionStates) },
) )
AsyncActionView( AsyncActionView(
async = state.cancelKnockAction, async = state.cancelKnockAction,
onSuccess = { onCancelKnockSuccess() }, onSuccess = { onCancelKnockSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, onErrorDismiss = { state.eventSink(JoinRoomEvent.ClearActionStates) },
errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, errorTitle = { stringResource(CommonStrings.common_something_went_wrong) },
errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) },
confirmationDialog = { confirmationDialog = {
@@ -195,8 +195,8 @@ fun JoinRoomView(
title = stringResource(R.string.screen_join_room_cancel_knock_alert_title), title = stringResource(R.string.screen_join_room_cancel_knock_alert_title),
submitText = stringResource(R.string.screen_join_room_cancel_knock_alert_confirmation), submitText = stringResource(R.string.screen_join_room_cancel_knock_alert_confirmation),
cancelText = stringResource(CommonStrings.action_no), cancelText = stringResource(CommonStrings.action_no),
onSubmitClick = { state.eventSink(JoinRoomEvents.CancelKnock(requiresConfirmation = false)) }, onSubmitClick = { state.eventSink(JoinRoomEvent.CancelKnock(requiresConfirmation = false)) },
onDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, onDismiss = { state.eventSink(JoinRoomEvent.ClearActionStates) },
) )
}, },
) )
@@ -12,7 +12,7 @@ import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvent
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData import io.element.android.features.invite.api.toInviteData
@@ -365,7 +365,7 @@ class JoinRoomPresenterTest {
@Test @Test
fun `present - when room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest { fun `present - when room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest {
val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> } val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvent -> }
val acceptDeclinePresenter = Presenter { val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder) anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
} }
@@ -388,14 +388,14 @@ class JoinRoomPresenterTest {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.AcceptInvite(inviteData)) state.eventSink(JoinRoomEvent.AcceptInvite(inviteData))
state.eventSink(JoinRoomEvents.DeclineInvite(inviteData, false)) state.eventSink(JoinRoomEvent.DeclineInvite(inviteData, false))
assert(eventSinkRecorder) assert(eventSinkRecorder)
.isCalledExactly(2) .isCalledExactly(2)
.withSequence( .withSequence(
listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))), listOf(value(AcceptDeclineInviteEvent.AcceptInvite(inviteData))),
listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true))), listOf(value(AcceptDeclineInviteEvent.DeclineInvite(inviteData, blockUser = false, shouldConfirm = true))),
) )
} }
} }
@@ -422,7 +422,7 @@ class JoinRoomPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.JoinRoom) state.eventSink(JoinRoomEvent.JoinRoom)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) assertThat(state.joinAction).isEqualTo(AsyncAction.Loading)
@@ -453,14 +453,14 @@ class JoinRoomPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.JoinRoom) state.eventSink(JoinRoomEvent.JoinRoom)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) assertThat(state.joinAction).isEqualTo(AsyncAction.Loading)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) assertThat(state.joinAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
state.eventSink(JoinRoomEvents.ClearActionStates) state.eventSink(JoinRoomEvent.ClearActionStates)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAction).isEqualTo(AsyncAction.Uninitialized) assertThat(state.joinAction).isEqualTo(AsyncAction.Uninitialized)
@@ -480,7 +480,7 @@ class JoinRoomPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.JoinRoom) state.eventSink(JoinRoomEvent.JoinRoom)
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) assertThat(state.joinAction).isEqualTo(AsyncAction.Loading)
@@ -683,17 +683,17 @@ class JoinRoomPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.UpdateKnockMessage(knockMessage)) state.eventSink(JoinRoomEvent.UpdateKnockMessage(knockMessage))
} }
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.KnockRoom) state.eventSink(JoinRoomEvent.KnockRoom)
} }
assertThat(awaitItem().knockAction).isEqualTo(AsyncAction.Loading) assertThat(awaitItem().knockAction).isEqualTo(AsyncAction.Loading)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.knockAction).isEqualTo(AsyncAction.Success(Unit)) assertThat(state.knockAction).isEqualTo(AsyncAction.Success(Unit))
fakeKnockRoom.lambda = knockRoomFailure fakeKnockRoom.lambda = knockRoomFailure
state.eventSink(JoinRoomEvents.KnockRoom) state.eventSink(JoinRoomEvent.KnockRoom)
} }
assertThat(awaitItem().knockAction).isEqualTo(AsyncAction.Loading) assertThat(awaitItem().knockAction).isEqualTo(AsyncAction.Loading)
@@ -731,17 +731,17 @@ class JoinRoomPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.CancelKnock(true)) state.eventSink(JoinRoomEvent.CancelKnock(true))
} }
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.ConfirmingNoParams) assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.ConfirmingNoParams)
state.eventSink(JoinRoomEvents.CancelKnock(false)) state.eventSink(JoinRoomEvent.CancelKnock(false))
} }
assertThat(awaitItem().cancelKnockAction).isEqualTo(AsyncAction.Loading) assertThat(awaitItem().cancelKnockAction).isEqualTo(AsyncAction.Loading)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.Success(Unit)) assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.Success(Unit))
cancelKnockRoom.lambda = cancelKnockRoomFailure cancelKnockRoom.lambda = cancelKnockRoomFailure
state.eventSink(JoinRoomEvents.CancelKnock(false)) state.eventSink(JoinRoomEvent.CancelKnock(false))
} }
assertThat(awaitItem().cancelKnockAction).isEqualTo(AsyncAction.Loading) assertThat(awaitItem().cancelKnockAction).isEqualTo(AsyncAction.Loading)
awaitItem().also { state -> awaitItem().also { state ->
@@ -778,14 +778,14 @@ class JoinRoomPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
state.eventSink(JoinRoomEvents.ForgetRoom) state.eventSink(JoinRoomEvent.ForgetRoom)
} }
assertThat(awaitItem().forgetAction).isEqualTo(AsyncAction.Loading) assertThat(awaitItem().forgetAction).isEqualTo(AsyncAction.Loading)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.forgetAction).isEqualTo(AsyncAction.Success(Unit)) assertThat(state.forgetAction).isEqualTo(AsyncAction.Success(Unit))
fakeForgetRoom.lambda = forgetRoomFailure fakeForgetRoom.lambda = forgetRoomFailure
state.eventSink(JoinRoomEvents.ForgetRoom) state.eventSink(JoinRoomEvent.ForgetRoom)
} }
assertThat(awaitItem().forgetAction).isEqualTo(AsyncAction.Loading) assertThat(awaitItem().forgetAction).isEqualTo(AsyncAction.Loading)
@@ -34,7 +34,7 @@ import org.junit.Test
class JoinRoomViewTest : RobolectricTest() { class JoinRoomViewTest : RobolectricTest() {
@Test @Test
fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest { fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<JoinRoomEvent>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
@@ -48,7 +48,7 @@ class JoinRoomViewTest : RobolectricTest() {
@Test @Test
fun `clicking on Join room on CanJoin room emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on Join room on CanJoin room emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin),
@@ -56,12 +56,12 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(R.string.screen_join_room_join_action) clickOn(R.string.screen_join_room_join_action)
eventsRecorder.assertSingle(JoinRoomEvents.JoinRoom) eventsRecorder.assertSingle(JoinRoomEvent.JoinRoom)
} }
@Test @Test
fun `clicking on Knock room on CanKnock room emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on Knock room on CanKnock room emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
@@ -70,12 +70,12 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(R.string.screen_join_room_knock_action) clickOn(R.string.screen_join_room_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.KnockRoom) eventsRecorder.assertSingle(JoinRoomEvent.KnockRoom)
} }
@Test @Test
fun `clicking on closing Knock error emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on closing Knock error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
@@ -84,12 +84,12 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) eventsRecorder.assertSingle(JoinRoomEvent.ClearActionStates)
} }
@Test @Test
fun `clicking on cancel knock request emit the expected Event`() = runAndroidComposeUiTest { fun `clicking on cancel knock request emit the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked),
@@ -97,12 +97,12 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(R.string.screen_join_room_cancel_knock_action) clickOn(R.string.screen_join_room_cancel_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.CancelKnock(true)) eventsRecorder.assertSingle(JoinRoomEvent.CancelKnock(true))
} }
@Test @Test
fun `clicking on closing Cancel Knock error emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on closing Cancel Knock error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked),
@@ -111,12 +111,12 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) eventsRecorder.assertSingle(JoinRoomEvent.ClearActionStates)
} }
@Test @Test
fun `clicking on closing Join error emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on closing Join error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
@@ -125,12 +125,12 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) eventsRecorder.assertSingle(JoinRoomEvent.ClearActionStates)
} }
@Test @Test
fun `when joining room is successful, the expected callback is invoked`() = runAndroidComposeUiTest { fun `when joining room is successful, the expected callback is invoked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<JoinRoomEvent>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
@@ -144,7 +144,7 @@ class JoinRoomViewTest : RobolectricTest() {
@Test @Test
fun `clicking on Accept when JoinAuthorisationStatus is IsInvited emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on Accept when JoinAuthorisationStatus is IsInvited emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
val inviteData = anInviteData() val inviteData = anInviteData()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
@@ -153,12 +153,12 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_accept) clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(JoinRoomEvents.AcceptInvite(inviteData)) eventsRecorder.assertSingle(JoinRoomEvent.AcceptInvite(inviteData))
} }
@Test @Test
fun `clicking on Decline when JoinAuthorisationStatus is IsInvited emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on Decline when JoinAuthorisationStatus is IsInvited emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
val inviteData = anInviteData() val inviteData = anInviteData()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
@@ -167,13 +167,13 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_decline) clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, false)) eventsRecorder.assertSingle(JoinRoomEvent.DeclineInvite(inviteData, false))
} }
@Test @Test
fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and can report room, the expected callback is invoked`() { fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and can report room, the expected callback is invoked`() {
runAndroidComposeUiTest { runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<JoinRoomEvent>(expectEvents = false)
val inviteData = anInviteData() val inviteData = anInviteData()
val joinRoomState = aJoinRoomState( val joinRoomState = aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())),
@@ -192,7 +192,7 @@ class JoinRoomViewTest : RobolectricTest() {
@Test @Test
fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and cant report room, emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and cant report room, emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
val inviteData = anInviteData() val inviteData = anInviteData()
val joinRoomState = aJoinRoomState( val joinRoomState = aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())),
@@ -201,12 +201,12 @@ class JoinRoomViewTest : RobolectricTest() {
) )
setJoinRoomView(state = joinRoomState) setJoinRoomView(state = joinRoomState)
clickOn(R.string.screen_join_room_decline_and_block_button_title) clickOn(R.string.screen_join_room_decline_and_block_button_title)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, true)) eventsRecorder.assertSingle(JoinRoomEvent.DeclineInvite(inviteData, true))
} }
@Test @Test
fun `clicking on Retry when an error occurs emits the expected Event`() = runAndroidComposeUiTest { fun `clicking on Retry when an error occurs emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aFailureContentState(), contentState = aFailureContentState(),
@@ -214,12 +214,12 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(CommonStrings.action_retry) clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(JoinRoomEvents.RetryFetchingContent) eventsRecorder.assertSingle(JoinRoomEvent.RetryFetchingContent)
} }
@Test @Test
fun `clicking on ok when user is unauthorized the expected callback`() = runAndroidComposeUiTest { fun `clicking on ok when user is unauthorized the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<JoinRoomEvent>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
@@ -235,7 +235,7 @@ class JoinRoomViewTest : RobolectricTest() {
@Test @Test
fun `clicking on forget when user is banned invokes the expected callback`() = runAndroidComposeUiTest { fun `clicking on forget when user is banned invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvent>()
setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null, null)), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null, null)),
@@ -243,7 +243,7 @@ class JoinRoomViewTest : RobolectricTest() {
), ),
) )
clickOn(R.string.screen_join_room_forget_action) clickOn(R.string.screen_join_room_forget_action)
eventsRecorder.assertSingle(JoinRoomEvents.ForgetRoom) eventsRecorder.assertSingle(JoinRoomEvent.ForgetRoom)
} }
} }
@@ -8,7 +8,7 @@
package io.element.android.features.knockrequests.impl.banner package io.element.android.features.knockrequests.impl.banner
sealed interface KnockRequestsBannerEvents { sealed interface KnockRequestsBannerEvent {
data object AcceptSingleRequest : KnockRequestsBannerEvents data object AcceptSingleRequest : KnockRequestsBannerEvent
data object Dismiss : KnockRequestsBannerEvents data object Dismiss : KnockRequestsBannerEvent
} }
@@ -53,15 +53,15 @@ class KnockRequestsBannerPresenter(
} }
} }
fun handleEvent(event: KnockRequestsBannerEvents) { fun handleEvent(event: KnockRequestsBannerEvent) {
when (event) { when (event) {
is KnockRequestsBannerEvents.AcceptSingleRequest -> { is KnockRequestsBannerEvent.AcceptSingleRequest -> {
sessionCoroutineScope.acceptSingleKnockRequest( sessionCoroutineScope.acceptSingleKnockRequest(
knockRequests = knockRequests, knockRequests = knockRequests,
displayAcceptError = showAcceptError, displayAcceptError = showAcceptError,
) )
} }
is KnockRequestsBannerEvents.Dismiss -> { is KnockRequestsBannerEvent.Dismiss -> {
sessionCoroutineScope.launch { sessionCoroutineScope.launch {
knockRequestsService.markAllKnockRequestsAsSeen() knockRequestsService.markAllKnockRequestsAsSeen()
} }
@@ -20,7 +20,7 @@ data class KnockRequestsBannerState(
val knockRequests: ImmutableList<KnockRequestPresentable>, val knockRequests: ImmutableList<KnockRequestPresentable>,
val displayAcceptError: Boolean, val displayAcceptError: Boolean,
val canAccept: Boolean, val canAccept: Boolean,
val eventSink: (KnockRequestsBannerEvents) -> Unit, val eventSink: (KnockRequestsBannerEvent) -> Unit,
) { ) {
val subtitle = knockRequests.singleOrNull()?.userId?.value val subtitle = knockRequests.singleOrNull()?.userId?.value
val reason = knockRequests.singleOrNull()?.reason val reason = knockRequests.singleOrNull()?.reason

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