Merge branch 'develop' into feature/fga/image_loading

This commit is contained in:
ganfra 2023-05-24 17:39:27 +02:00
commit 922b63e746
104 changed files with 1791 additions and 114 deletions

View file

@ -42,7 +42,7 @@ jobs:
name: elementx-debug name: elementx-debug
path: | path: |
app/build/outputs/apk/debug/*.apk app/build/outputs/apk/debug/*.apk
- uses: mobile-dev-inc/action-maestro-cloud@v1.3.1 - uses: mobile-dev-inc/action-maestro-cloud@v1.3.2
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: app/build/outputs/apk/debug/app-universal-debug.apk app-file: app/build/outputs/apk/debug/app-universal-debug.apk

43
.github/workflows/nightlyReports.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Nightly reports
on:
workflow_dispatch:
schedule:
# Every nights at 5
- cron: "0 5 * * *"
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4
jobs:
nightlyReports:
name: Create kover report artifact and upload sonar result.
runs-on: ubuntu-latest
if: ${{ github.repository == 'vector-im/element-x-android' }}
steps:
- uses: actions/checkout@v3
- name: Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: ⚙️ Run unit & screenshot tests, generate kover report
run: ./gradlew koverMergedReport $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
- name: ✅ Upload kover report
if: always()
uses: actions/upload-artifact@v3
with:
name: kover-results
path: |
**/build/reports/kover/merged
- name: 🔊 Publish results to Sonar
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES

View file

@ -68,13 +68,9 @@ jobs:
path: tests/uitests/out/failures/ path: tests/uitests/out/failures/
retention-days: 5 retention-days: 5
- name: ✅ Upload kover report - name: ✅ Upload kover report (disabled)
if: always() if: always()
uses: actions/upload-artifact@v3 run: echo "This is now done only once a day, see nightlyReports.yml"
with:
name: kover-results
path: |
**/build/reports/kover/merged
- name: 🚫 Upload test results on error - name: 🚫 Upload test results on error
if: failure() if: failure()
@ -85,12 +81,8 @@ jobs:
**/out/failures/ **/out/failures/
**/build/reports/tests/*UnitTest/ **/build/reports/tests/*UnitTest/
- name: 🔊 Publish results to Sonar - name: 🔊 Publish results to Sonar (disabled)
env: run: echo "This is now done only once a day, see nightlyReports.yml"
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
# https://github.com/codecov/codecov-action # https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov - name: ☂️ Upload coverage reports to codecov

View file

@ -25,6 +25,6 @@ dependencies {
implementation(libs.anvil.compiler.utils) implementation(libs.anvil.compiler.utils)
implementation("com.squareup:kotlinpoet:1.13.2") implementation("com.squareup:kotlinpoet:1.13.2")
implementation(libs.dagger) implementation(libs.dagger)
compileOnly("com.google.auto.service:auto-service-annotations:1.0.1") compileOnly("com.google.auto.service:auto-service-annotations:1.1.0")
kapt("com.google.auto.service:auto-service:1.0.1") kapt("com.google.auto.service:auto-service:1.1.0")
} }

View file

@ -56,6 +56,7 @@ dependencies {
implementation(projects.tests.uitests) implementation(projects.tests.uitests)
implementation(libs.coil) implementation(libs.coil)
implementation(projects.services.apperror.impl)
implementation(projects.services.appnavstate.api) implementation(projects.services.appnavstate.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -17,24 +17,30 @@
package io.element.android.appnav.root package io.element.android.appnav.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.services.apperror.api.AppErrorStateService
import javax.inject.Inject import javax.inject.Inject
class RootPresenter @Inject constructor( class RootPresenter @Inject constructor(
private val crashDetectionPresenter: CrashDetectionPresenter, private val crashDetectionPresenter: CrashDetectionPresenter,
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter, private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
private val appErrorStateService: AppErrorStateService,
) : Presenter<RootState> { ) : Presenter<RootState> {
@Composable @Composable
override fun present(): RootState { override fun present(): RootState {
val rageshakeDetectionState = rageshakeDetectionPresenter.present() val rageshakeDetectionState = rageshakeDetectionPresenter.present()
val crashDetectionState = crashDetectionPresenter.present() val crashDetectionState = crashDetectionPresenter.present()
val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState()
return RootState( return RootState(
rageshakeDetectionState = rageshakeDetectionState, rageshakeDetectionState = rageshakeDetectionState,
crashDetectionState = crashDetectionState, crashDetectionState = crashDetectionState,
errorState = appErrorState,
) )
} }
} }

View file

@ -19,9 +19,11 @@ package io.element.android.appnav.root
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.rageshake.api.crash.CrashDetectionState import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
import io.element.android.services.apperror.api.AppErrorState
@Immutable @Immutable
data class RootState( data class RootState(
val rageshakeDetectionState: RageshakeDetectionState, val rageshakeDetectionState: RageshakeDetectionState,
val crashDetectionState: CrashDetectionState, val crashDetectionState: CrashDetectionState,
val errorState: AppErrorState,
) )

View file

@ -19,6 +19,8 @@ package io.element.android.appnav.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.rageshake.api.crash.aCrashDetectionState import io.element.android.features.rageshake.api.crash.aCrashDetectionState
import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.aAppErrorState
open class RootStateProvider : PreviewParameterProvider<RootState> { open class RootStateProvider : PreviewParameterProvider<RootState> {
override val values: Sequence<RootState> override val values: Sequence<RootState>
@ -30,6 +32,9 @@ open class RootStateProvider : PreviewParameterProvider<RootState> {
aRootState().copy( aRootState().copy(
rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true), rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true),
crashDetectionState = aCrashDetectionState().copy(crashDetected = false), crashDetectionState = aCrashDetectionState().copy(crashDetected = false),
),
aRootState().copy(
errorState = aAppErrorState(),
) )
) )
} }
@ -37,4 +42,5 @@ open class RootStateProvider : PreviewParameterProvider<RootState> {
fun aRootState() = RootState( fun aRootState() = RootState(
rageshakeDetectionState = aRageshakeDetectionState(), rageshakeDetectionState = aRageshakeDetectionState(),
crashDetectionState = aCrashDetectionState(), crashDetectionState = aCrashDetectionState(),
errorState = AppErrorState.NoError,
) )

View file

@ -31,6 +31,7 @@ import io.element.android.features.rageshake.api.detection.RageshakeDetectionVie
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.services.apperror.impl.AppErrorView
@Composable @Composable
fun RootView( fun RootView(
@ -60,6 +61,9 @@ fun RootView(
state = state.crashDetectionState, state = state.crashDetectionState,
onOpenBugReport = ::onOpenBugReport, onOpenBugReport = ::onOpenBugReport,
) )
AppErrorView(
state = state.errorState,
)
} }
} }

View file

@ -28,6 +28,9 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.rageshake.FakeRageShake import io.element.android.features.rageshake.test.rageshake.FakeRageShake
import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -44,7 +47,32 @@ class RootPresenterTest {
} }
} }
private fun createPresenter(): RootPresenter { @Test
fun `present - passes app error state`() = runTest {
val presenter = createPresenter(
appErrorService = DefaultAppErrorStateService().apply {
showError("Bad news", "Something bad happened")
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.errorState).isInstanceOf(AppErrorState.Error::class.java)
val initialErrorState = initialState.errorState as AppErrorState.Error
assertThat(initialErrorState.title).isEqualTo("Bad news")
assertThat(initialErrorState.body).isEqualTo("Something bad happened")
initialErrorState.dismiss()
assertThat(awaitItem().errorState).isInstanceOf(AppErrorState.NoError::class.java)
}
}
private fun createPresenter(
appErrorService: AppErrorStateService = DefaultAppErrorStateService()
): RootPresenter {
val crashDataStore = FakeCrashDataStore() val crashDataStore = FakeCrashDataStore()
val rageshakeDataStore = FakeRageshakeDataStore() val rageshakeDataStore = FakeRageshakeDataStore()
val rageshake = FakeRageShake() val rageshake = FakeRageShake()
@ -63,6 +91,7 @@ class RootPresenterTest {
return RootPresenter( return RootPresenter(
crashDetectionPresenter = crashDetectionPresenter, crashDetectionPresenter = crashDetectionPresenter,
rageshakeDetectionPresenter = rageshakeDetectionPresenter, rageshakeDetectionPresenter = rageshakeDetectionPresenter,
appErrorStateService = appErrorService,
) )
} }
} }

View file

@ -59,7 +59,7 @@ allprojects {
config = files("$rootDir/tools/detekt/detekt.yml") config = files("$rootDir/tools/detekt/detekt.yml")
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.1.5") detektPlugins("io.nlopez.compose.rules:detekt:0.1.6")
} }
// KtLint // KtLint

1
changelog.d/245.feature Normal file
View file

@ -0,0 +1 @@
[Create and join rooms] Add ability to invite users to existing rooms

1
changelog.d/420.doc Normal file
View file

@ -0,0 +1 @@
Update design documentation to add references to Compound

1
changelog.d/437.feature Normal file
View file

@ -0,0 +1 @@
Handle "Invite people" action in the start a chat flow

View file

@ -8,11 +8,12 @@
* [Text](#text) * [Text](#text)
* [Dimension, position and margin](#dimension-position-and-margin) * [Dimension, position and margin](#dimension-position-and-margin)
* [Icons](#icons) * [Icons](#icons)
* [Export drawable from Figma](#export-drawable-from-figma) * [Custom icons](#custom-icons)
* [Import in Android Studio](#import-in-android-studio) * [Export drawable from Figma](#export-drawable-from-figma)
* [Import in Android Studio](#import-in-android-studio)
* [Images](#images) * [Images](#images)
* [Figma links](#figma-links) * [Figma links](#figma-links)
* [Coumpound](#coumpound) * [Compound](#compound)
* [Login](#login) * [Login](#login)
* [Login v2](#login-v2) * [Login v2](#login-v2)
* [Room list](#room-list) * [Room list](#room-list)
@ -31,6 +32,7 @@
## Introduction ## Introduction
Design at element.io is done using Figma - https://www.figma.com Design at element.io is done using Figma - https://www.figma.com
You will find guidance to build using interface on the [Compound documentation Element's design system](https://compound.element.io)
## How to import from Figma to the Element Android project ## How to import from Figma to the Element Android project
@ -41,6 +43,9 @@ Integration should be done using the Android development best practice, and shou
Element Android already contains all the colors which can be used by the designer, in the module `ui-style`. Element Android already contains all the colors which can be used by the designer, in the module `ui-style`.
Some of them depend on the theme, so ensure to use theme attributes and not colors directly. Some of them depend on the theme, so ensure to use theme attributes and not colors directly.
A comprehensive [color definition documentation](https://compound.element.io/?path=/docs/tokens-color-palettes--docs) is available in Compound.
### Text ### Text
- click on a text on Figma - click on a text on Figma
@ -56,7 +61,16 @@ Some of them depend on the theme, so ensure to use theme attributes and not colo
### Icons ### Icons
#### Export drawable from Figma Most icons should be available as part of the [Compound icon library](https://compound.element.io/?path=/docs/tokens-icons--docs)
All drawable are auto-generated as part of the design tokens library. You can find
all assets in [`vector-im/compound-design-tokens#assets/android`](https://github.com/vector-im/compound-design-tokens/tree/develop/assets/android)
If you are missing an icon, follow to [contribution guidelines for icons](https://www.figma.com/file/gkNXqPoiJhEv2wt0EJpew4/Compound-Icons?type=design&node-id=178-3119&t=j2uSJD9xPXJn5aRM-0)
#### Custom icons
##### Export drawable from Figma
- click on the element to export - click on the element to export
- ensure that the correct layer is selected. Sometimes the parent layer has to be selected on the left panel - ensure that the correct layer is selected. Sometimes the parent layer has to be selected on the left panel
@ -68,7 +82,7 @@ Some of them depend on the theme, so ensure to use theme attributes and not colo
It's also possible for any icon to go to the main component by right-clicking on the icon. It's also possible for any icon to go to the main component by right-clicking on the icon.
#### Import in Android Studio ##### Import in Android Studio
- right click on the drawable folder where the drawable will be created - right click on the drawable folder where the drawable will be created
- click on "New"/"Vector Asset" - click on "New"/"Vector Asset"
@ -97,11 +111,15 @@ Main entry point: https://www.figma.com/files/project/5612863/Element?fuid=77937
Note: all the Figma links are not publicly available. Note: all the Figma links are not publicly available.
### Coumpound ### Compound
Coumpound contains the theme of the application, with all the components, in Light and Dark theme: palette (colors), typography, iconography, etc. Compound is Element's design system where you'll find styles and documentation
regarding user interfaces.
https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound - Documentation: [https://compound.element.io](https://compound.element.io)
- [Compound Android Figma document](https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components)
- [Compound Styles - Figma document](https://www.figma.com/file/PpKepmHKGikp33Ql7iivbn/Compound-Styles?type=design)
- [Compound Icons - Figma document](https://www.figma.com/file/gkNXqPoiJhEv2wt0EJpew4/Compound-Icons)
### Login ### Login

View file

@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -54,13 +55,17 @@ fun AddPeopleView(
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
if (!state.isSearchActive) { AddPeopleViewTopBar(
AddPeopleViewTopBar( hasSelectedUsers = state.selectedUsers.isNotEmpty(),
hasSelectedUsers = state.selectedUsers.isNotEmpty(), onBackPressed = {
onBackPressed = onBackPressed, if (state.isSearchActive) {
onNextPressed = onNextPressed, state.eventSink(UserListEvents.OnSearchActiveChanged(false))
) } else {
} onBackPressed()
}
},
onNextPressed = onNextPressed,
)
} }
) { padding -> ) { padding ->
Column( Column(
@ -73,6 +78,7 @@ fun AddPeopleView(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
state = state, state = state,
showBackButton = false,
) )
} }
} }

View file

@ -39,6 +39,7 @@ fun SearchUserBar(
active: Boolean, active: Boolean,
isMultiSelectionEnabled: Boolean, isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showBackButton: Boolean = true,
placeHolderTitle: String = stringResource(R.string.common_search_for_someone), placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {}, onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {}, onTextChanged: (String) -> Unit = {},
@ -52,6 +53,7 @@ fun SearchUserBar(
onActiveChange = onActiveChanged, onActiveChange = onActiveChanged,
modifier = modifier, modifier = modifier,
placeHolderTitle = placeHolderTitle, placeHolderTitle = placeHolderTitle,
showBackButton = showBackButton,
contentPrefix = { contentPrefix = {
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) { if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
SelectedUsersList( SelectedUsersList(

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.ui.components.SelectedUsersList
fun UserListView( fun UserListView(
state: UserListState, state: UserListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showBackButton: Boolean = true,
onUserSelected: (MatrixUser) -> Unit = {}, onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {}, onUserDeselected: (MatrixUser) -> Unit = {},
) { ) {
@ -49,6 +50,7 @@ fun UserListView(
selectedUsers = state.selectedUsers, selectedUsers = state.selectedUsers,
active = state.isSearchActive, active = state.isSearchActive,
isMultiSelectionEnabled = state.isMultiSelectionEnabled, isMultiSelectionEnabled = state.isMultiSelectionEnabled,
showBackButton = showBackButton,
onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) },
onUserSelected = { onUserSelected = {

View file

@ -19,7 +19,6 @@ package io.element.android.features.createroom.impl.root
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface CreateRoomRootEvents { sealed interface CreateRoomRootEvents {
object InvitePeople : CreateRoomRootEvents
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
object CancelStartDM : CreateRoomRootEvents object CancelStartDM : CreateRoomRootEvents
} }

View file

@ -16,8 +16,10 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@ -25,14 +27,22 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.ui.strings.R
import timber.log.Timber
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class CreateRoomRootNode @AssistedInject constructor( class CreateRoomRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: CreateRoomRootPresenter, private val presenter: CreateRoomRootPresenter,
private val matrixClient: MatrixClient,
private val buildMeta: BuildMeta,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
@ -53,12 +63,31 @@ class CreateRoomRootNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val context = LocalContext.current
CreateRoomRootView( CreateRoomRootView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onClosePressed = this::navigateUp, onClosePressed = this::navigateUp,
onNewRoomClicked = callback::onCreateNewRoom, onNewRoomClicked = callback::onCreateNewRoom,
onOpenDM = callback::onStartChatSuccess, onOpenDM = callback::onStartChatSuccess,
onInviteFriendsClicked = { invitePeople(context) }
) )
} }
private fun invitePeople(context: Context) {
val permalinkResult = PermalinkBuilder.permalinkForUser(matrixClient.sessionId)
permalinkResult.onSuccess { permalink ->
val appName = buildMeta.applicationName
startSharePlainTextIntent(
context = context,
activityResultLauncher = null,
chooserTitle = context.getString(R.string.action_invite_friends),
text = context.getString(R.string.invite_friends_text, appName, permalink),
extraTitle = context.getString(R.string.invite_friends_rich_title, appName),
noActivityFoundMessage = context.getString(io.element.android.libraries.androidutils.R.string.error_no_compatible_app_found)
)
}.onFailure {
Timber.e(it)
}
}
} }

View file

@ -75,7 +75,6 @@ class CreateRoomRootPresenter @Inject constructor(
when (event) { when (event) {
is CreateRoomRootEvents.StartDM -> startDm(event.matrixUser) is CreateRoomRootEvents.StartDM -> startDm(event.matrixUser)
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action
} }
} }

View file

@ -64,6 +64,7 @@ fun CreateRoomRootView(
onClosePressed: () -> Unit = {}, onClosePressed: () -> Unit = {},
onNewRoomClicked: () -> Unit = {}, onNewRoomClicked: () -> Unit = {},
onOpenDM: (RoomId) -> Unit = {}, onOpenDM: (RoomId) -> Unit = {},
onInviteFriendsClicked: () -> Unit = {},
) { ) {
if (state.startDmAction is Async.Success) { if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) { LaunchedEffect(state.startDmAction) {
@ -96,7 +97,7 @@ fun CreateRoomRootView(
if (!state.userListState.isSearchActive) { if (!state.userListState.isSearchActive) {
CreateRoomActionButtonsList( CreateRoomActionButtonsList(
onNewRoomClicked = onNewRoomClicked, onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = { state.eventSink(CreateRoomRootEvents.InvitePeople) }, onInvitePeopleClicked = onInviteFriendsClicked,
) )
} }
} }

View file

@ -62,16 +62,6 @@ class CreateRoomRootPresenterTests {
} }
} }
@Test
fun `present - trigger action buttons`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(CreateRoomRootEvents.InvitePeople) // Not implemented yet
}
}
@Test @Test
fun `present - trigger create DM action`() = runTest { fun `present - trigger create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api) api(projects.libraries.usersearch.api)
api(projects.services.apperror.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -51,6 +52,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -28,6 +28,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
@ -57,6 +58,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
object RoomMemberList : NavTarget object RoomMemberList : NavTarget
@Parcelize
object InviteMembers : NavTarget
@Parcelize @Parcelize
data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget data class RoomMemberDetails(val roomMemberId: UserId) : NavTarget
} }
@ -68,6 +72,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openRoomMemberList() { override fun openRoomMemberList() {
backstack.push(NavTarget.RoomMemberList) backstack.push(NavTarget.RoomMemberList)
} }
override fun openInviteMembers() {
backstack.push(NavTarget.InviteMembers)
}
} }
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback)) createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
} }
@ -76,9 +84,16 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openRoomMemberDetails(roomMemberId: UserId) { override fun openRoomMemberDetails(roomMemberId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(roomMemberId)) backstack.push(NavTarget.RoomMemberDetails(roomMemberId))
} }
override fun openInviteMembers() {
backstack.push(NavTarget.InviteMembers)
}
} }
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback)) createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
} }
NavTarget.InviteMembers -> {
createNode<RoomInviteMembersNode>(buildContext)
}
is NavTarget.RoomMemberDetails -> { is NavTarget.RoomMemberDetails -> {
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId))) createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMemberId)))
} }

View file

@ -45,6 +45,7 @@ class RoomDetailsNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun openRoomMemberList() fun openRoomMemberList()
fun openInviteMembers()
} }
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
@ -53,6 +54,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openRoomMemberList() } callbacks.forEach { it.openRoomMemberList() }
} }
private fun invitePeople() {
callbacks.forEach { it.openInviteMembers() }
}
private fun onShareRoom(context: Context) { private fun onShareRoom(context: Context) {
val alias = room.alias ?: room.alternativeAliases.firstOrNull() val alias = room.alias ?: room.alternativeAliases.firstOrNull()
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) } val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
@ -105,6 +110,7 @@ class RoomDetailsNode @AssistedInject constructor(
onShareRoom = ::onShareRoom, onShareRoom = ::onShareRoom,
onShareMember = ::onShareMember, onShareMember = ::onShareMember,
openRoomMemberList = ::openRoomMemberList, openRoomMemberList = ::openRoomMemberList,
invitePeople = ::invitePeople,
) )
} }
} }

View file

@ -62,6 +62,7 @@ class RoomDetailsPresenter @Inject constructor(
val membersState by room.membersStateFlow.collectAsState() val membersState by room.membersStateFlow.collectAsState()
val memberCount by getMemberCount(membersState) val memberCount by getMemberCount(membersState)
val canInvite by getCanInvite(membersState)
val dmMember by room.getDirectRoomMember(membersState) val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType = getRoomType(dmMember) val roomType = getRoomType(dmMember)
@ -76,7 +77,7 @@ class RoomDetailsPresenter @Inject constructor(
error = error, error = error,
) )
} }
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning.value = null
RoomDetailsEvent.ClearError -> error.value = null RoomDetailsEvent.ClearError -> error.value = null
} }
} }
@ -91,6 +92,7 @@ class RoomDetailsPresenter @Inject constructor(
roomTopic = room.topic, roomTopic = room.topic,
memberCount = memberCount, memberCount = memberCount,
isEncrypted = room.isEncrypted, isEncrypted = room.isEncrypted,
canInvite = canInvite,
displayLeaveRoomWarning = leaveRoomWarning.value, displayLeaveRoomWarning = leaveRoomWarning.value,
error = error.value, error = error.value,
roomType = roomType.value, roomType = roomType.value,
@ -117,6 +119,15 @@ class RoomDetailsPresenter @Inject constructor(
} }
} }
@Composable
private fun getCanInvite(membersState: MatrixRoomMembersState): State<Boolean> {
val canInvite = remember(membersState) { mutableStateOf(false) }
LaunchedEffect(membersState) {
canInvite.value = room.canInvite().getOrElse { false }
}
return canInvite
}
@Composable @Composable
private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> { private fun getMemberCount(membersState: MatrixRoomMembersState): State<Async<Int>> {
return remember(membersState) { return remember(membersState) {

View file

@ -32,6 +32,7 @@ data class RoomDetailsState(
val error: RoomDetailsError?, val error: RoomDetailsError?,
val roomType: RoomDetailsType, val roomType: RoomDetailsType,
val roomMemberDetailsState: RoomMemberDetailsState?, val roomMemberDetailsState: RoomMemberDetailsState?,
val canInvite: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) )

View file

@ -33,6 +33,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())), aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
aDmRoomDetailsState().copy(roomName = "Daniel"), aDmRoomDetailsState().copy(roomName = "Daniel"),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"), aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
aRoomDetailsState().copy(canInvite = true),
// Add other state here // Add other state here
) )
} }
@ -71,6 +72,7 @@ fun aRoomDetailsState() = RoomDetailsState(
isEncrypted = true, isEncrypted = true,
displayLeaveRoomWarning = null, displayLeaveRoomWarning = null,
error = null, error = null,
canInvite = false,
roomType = RoomDetailsType.Room, roomType = RoomDetailsType.Room,
roomMemberDetailsState = null, roomMemberDetailsState = null,
eventSink = {} eventSink = {}

View file

@ -78,6 +78,7 @@ fun RoomDetailsView(
onShareRoom: () -> Unit, onShareRoom: () -> Unit,
onShareMember: (RoomMember) -> Unit, onShareMember: (RoomMember) -> Unit,
openRoomMemberList: () -> Unit, openRoomMemberList: () -> Unit,
invitePeople: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -127,7 +128,9 @@ fun RoomDetailsView(
MembersSection( MembersSection(
memberCount = memberCount, memberCount = memberCount,
isLoading = state.memberCount.isLoading(), isLoading = state.memberCount.isLoading(),
openRoomMemberList = openRoomMemberList showInvite = state.canInvite,
openRoomMemberList = openRoomMemberList,
invitePeople = invitePeople,
) )
} }
@ -211,8 +214,10 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
internal fun MembersSection( internal fun MembersSection(
memberCount: Int?, memberCount: Int?,
isLoading: Boolean, isLoading: Boolean,
showInvite: Boolean,
invitePeople: () -> Unit,
openRoomMemberList: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
openRoomMemberList: () -> Unit
) { ) {
PreferenceCategory(modifier = modifier) { PreferenceCategory(modifier = modifier) {
PreferenceText( PreferenceText(
@ -222,10 +227,13 @@ internal fun MembersSection(
onClick = openRoomMemberList, onClick = openRoomMemberList,
loadingCurrentValue = isLoading, loadingCurrentValue = isLoading,
) )
PreferenceText( if (showInvite) {
title = stringResource(R.string.screen_room_details_invite_people_title), PreferenceText(
icon = Icons.Outlined.PersonAddAlt, title = stringResource(R.string.screen_room_details_invite_people_title),
) icon = Icons.Outlined.PersonAddAlt,
onClick = invitePeople,
)
}
} }
} }
@ -291,5 +299,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
onShareRoom = {}, onShareRoom = {},
onShareMember = {}, onShareMember = {},
openRoomMemberList = {}, openRoomMemberList = {},
invitePeople = {},
) )
} }

View file

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

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.apperror.api.AppErrorStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@ContributesNode(RoomScope::class)
class RoomInviteMembersNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
coroutineDispatchers: CoroutineDispatchers,
private val room: MatrixRoom,
private val presenter: RoomInviteMembersPresenter,
private val appErrorStateService: AppErrorStateService,
) : Node(buildContext, plugins = plugins) {
private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatchers.io)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current.applicationContext
RoomInviteMembersView(
state = state,
modifier = modifier,
onBackPressed = { navigateUp() },
onSendPressed = { users ->
navigateUp()
coroutineScope.launch {
val anyInviteFailed = users
.map { room.inviteUserById(it.userId) }
.any { it.isFailure }
if (anyInviteFailed) {
appErrorStateService.showError(
title = context.getString(StringR.string.common_unable_to_invite_title),
body = context.getString(StringR.string.common_unable_to_invite_message),
)
}
room.updateMembers()
}
}
)
}
}

View file

@ -0,0 +1,152 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomInviteMembersPresenter @Inject constructor(
private val userRepository: UserRepository,
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomInviteMembersState> {
@Composable
override fun present(): RoomInviteMembersState {
val roomMembers = remember { mutableStateOf<Async<ImmutableList<RoomMember>>>(Async.Loading()) }
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.NotSearching()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
fetchMembers(roomMembers)
}
LaunchedEffect(searchQuery, roomMembers) {
performSearch(searchResults, roomMembers, selectedUsers, searchQuery)
}
return RoomInviteMembersState(
canInvite = selectedUsers.value.isNotEmpty(),
selectedUsers = selectedUsers.value,
searchQuery = searchQuery,
isSearchActive = searchActive,
searchResults = searchResults.value,
eventSink = {
when (it) {
is RoomInviteMembersEvents.OnSearchActiveChanged -> {
searchActive = it.active
searchQuery = ""
}
is RoomInviteMembersEvents.UpdateSearchQuery -> {
searchQuery = it.query
}
is RoomInviteMembersEvents.ToggleUser -> {
selectedUsers.toggleUser(it.user)
searchResults.toggleUser(it.user)
}
}
}
)
}
@JvmName("toggleUserInSelectedUsers")
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) {
value.filterNot { it == user }
} else {
(value + user)
}.toImmutableList()
}
@JvmName("toggleUserInSearchResults")
private fun MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>.toggleUser(user: MatrixUser) {
val existingResults = value
if (existingResults is SearchBarResultState.Results) {
value = SearchBarResultState.Results(
existingResults.results.map { iu ->
if (iu.matrixUser == user) {
iu.copy(isSelected = !iu.isSelected)
} else {
iu
}
}.toImmutableList()
)
}
}
private suspend fun performSearch(
searchResults: MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>,
roomMembers: MutableState<Async<ImmutableList<RoomMember>>>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
searchQuery: String,
) = withContext(coroutineDispatchers.io) {
searchResults.value = SearchBarResultState.NotSearching()
val joinedMembers = roomMembers.value.dataOrNull().orEmpty()
userRepository.search(searchQuery).collect {
searchResults.value = when {
it.isEmpty() -> SearchBarResultState.NoResults()
else -> SearchBarResultState.Results(it.map { user ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == user.userId }?.membership
val isJoined = existingMembership == RoomMembershipState.JOIN
val isInvited = existingMembership == RoomMembershipState.INVITE
InvitableUser(
matrixUser = user,
isSelected = selectedUsers.value.contains(user) || isJoined || isInvited,
isAlreadyJoined = isJoined,
isAlreadyInvited = isInvited,
)
}.toImmutableList())
}
}
}
private suspend fun fetchMembers(roomMembers: MutableState<Async<ImmutableList<RoomMember>>>) {
suspend {
withContext(coroutineDispatchers.io) {
roomMemberListDataSource.search("").toImmutableList()
}
}.execute(roomMembers)
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.invite
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class RoomInviteMembersState(
val canInvite: Boolean = false,
val searchQuery: String = "",
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.NotSearching(),
val selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
val isSearchActive: Boolean = false,
val eventSink: (RoomInviteMembersEvents) -> Unit = {},
)
data class InvitableUser(
val matrixUser: MatrixUser,
val isSelected: Boolean = false,
val isAlreadyJoined: Boolean = false,
val isAlreadyInvited: Boolean = false,
)

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInviteMembersState> {
override val values: Sequence<RoomInviteMembersState>
get() = sequenceOf(
RoomInviteMembersState(),
RoomInviteMembersState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query"),
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()),
RoomInviteMembersState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResults()),
RoomInviteMembersState(
isSearchActive = true,
canInvite = true,
searchQuery = "some query",
selectedUsers = persistentListOf(
aMatrixUser("@carol:server.org", "Carol")
),
searchResults = SearchBarResultState.Results(
persistentListOf(
InvitableUser(aMatrixUser("@alice:server.org")),
InvitableUser(aMatrixUser("@bob:server.org", "Bob")),
InvitableUser(aMatrixUser("@carol:server.org", "Carol"), isSelected = true),
InvitableUser(aMatrixUser("@eve:server.org", "Eve"), isSelected = true, isAlreadyJoined = true),
InvitableUser(aMatrixUser("@justin:server.org", "Justin"), isSelected = true, isAlreadyInvited = true),
)
)
),
)
}

View file

@ -0,0 +1,218 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RoomInviteMembersView(
state: RoomInviteMembersState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onSendPressed: (List<MatrixUser>) -> Unit = {},
) {
Scaffold(
topBar = {
RoomInviteMembersTopBar(
onBackPressed = {
if (state.isSearchActive) {
state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(false))
} else {
onBackPressed()
}
},
onSendPressed = { onSendPressed(state.selectedUsers) },
canSend = state.canInvite,
)
}
) { padding ->
Column(
modifier = modifier
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RoomInviteMembersSearchBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
selectedUsers = state.selectedUsers,
state = state.searchResults,
active = state.isSearchActive,
onActiveChanged = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) },
onUserToggled = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) },
)
if (!state.isSearchActive) {
SelectedUsersList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = state.selectedUsers,
autoScroll = true,
onUserRemoved = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) },
contentPadding = PaddingValues(16.dp),
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomInviteMembersTopBar(
canSend: Boolean,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onSendPressed: () -> Unit = {},
) {
CenterAlignedTopAppBar(
modifier = modifier,
title = {
Text(
text = stringResource(R.string.screen_room_details_invite_people_title),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
TextButton(
onClick = onSendPressed,
content = {
Text(stringResource(StringR.string.action_send))
},
enabled = canSend,
)
}
)
}
@Composable
private fun RoomInviteMembersSearchBar(
query: String,
state: SearchBarResultState<ImmutableList<InvitableUser>>,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(io.element.android.libraries.ui.strings.R.string.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onUserToggled: (MatrixUser) -> Unit = {},
) {
SearchBar(
query = query,
onQueryChange = onTextChanged,
active = active,
onActiveChange = onActiveChanged,
modifier = modifier,
placeHolderTitle = placeHolderTitle,
contentPrefix = {
if (selectedUsers.isNotEmpty()) {
SelectedUsersList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemoved = onUserToggled,
contentPadding = PaddingValues(16.dp),
)
}
},
showBackButton = false,
resultState = state,
resultHandler = { results ->
Text(
text = "Search results",
fontWeight = FontWeight.Medium,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 8.dp)
)
LazyColumn {
items(results) { invitableUser ->
CheckableUserRow(
checked = invitableUser.isSelected,
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
name = invitableUser.matrixUser.getBestName(),
subtext = when {
// If they're already invited or joined we show that information
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member)
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited)
// Otherwise show the ID, unless that's already used for their name
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value
else -> null
},
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
modifier = Modifier.fillMaxWidth()
)
}
}
},
)
}
@Preview
@Composable
fun RoomInviteMembersLightPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun RoomInviteMembersDarkPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: RoomInviteMembersState) {
RoomInviteMembersView(state)
}

View file

@ -27,7 +27,6 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class RoomMemberListNode @AssistedInject constructor( class RoomMemberListNode @AssistedInject constructor(
@ -38,6 +37,7 @@ class RoomMemberListNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun openRoomMemberDetails(roomMemberId: UserId) fun openRoomMemberDetails(roomMemberId: UserId)
fun openInviteMembers()
} }
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
@ -48,6 +48,12 @@ class RoomMemberListNode @AssistedInject constructor(
} }
} }
private fun openInviteMembers() {
callbacks.forEach {
it.openInviteMembers()
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@ -56,6 +62,7 @@ class RoomMemberListNode @AssistedInject constructor(
modifier = modifier, modifier = modifier,
onBackPressed = { navigateUp() }, onBackPressed = { navigateUp() },
onMemberSelected = this::openRoomMemberDetails, onMemberSelected = this::openRoomMemberDetails,
onInvitePressed = this::openInviteMembers,
) )
} }
} }

View file

@ -18,6 +18,8 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -27,12 +29,15 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
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
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
class RoomMemberListPresenter @Inject constructor( class RoomMemberListPresenter @Inject constructor(
private val room: MatrixRoom,
private val roomMemberListDataSource: RoomMemberListDataSource, private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<RoomMemberListState> { ) : Presenter<RoomMemberListState> {
@ -46,6 +51,9 @@ class RoomMemberListPresenter @Inject constructor(
} }
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
val membersState by room.membersStateFlow.collectAsState()
val canInvite by getCanInvite(membersState = membersState)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
val members = roomMemberListDataSource.search("").groupBy { it.membership } val members = roomMemberListDataSource.search("").groupBy { it.membership }
@ -80,6 +88,7 @@ class RoomMemberListPresenter @Inject constructor(
searchQuery = searchQuery, searchQuery = searchQuery,
searchResults = searchResults, searchResults = searchResults,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
canInvite = canInvite,
eventSink = { event -> eventSink = { event ->
when (event) { when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
@ -88,5 +97,14 @@ class RoomMemberListPresenter @Inject constructor(
}, },
) )
} }
@Composable
private fun getCanInvite(membersState: MatrixRoomMembersState): State<Boolean> {
val canInvite = remember(membersState) { mutableStateOf(false) }
LaunchedEffect(membersState) {
canInvite.value = room.canInvite().getOrElse { false }
}
return canInvite
}
} }

View file

@ -26,6 +26,7 @@ data class RoomMemberListState(
val searchQuery: String, val searchQuery: String,
val searchResults: SearchBarResultState<RoomMembers>, val searchResults: SearchBarResultState<RoomMembers>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val canInvite: Boolean,
val eventSink: (RoomMemberListEvents) -> Unit, val eventSink: (RoomMemberListEvents) -> Unit,
) )

View file

@ -36,6 +36,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
) )
), ),
aRoomMemberListState(roomMembers = Async.Loading()), aRoomMemberListState(roomMembers = Async.Loading()),
aRoomMemberListState().copy(canInvite = true),
aRoomMemberListState().copy(isSearchActive = false), aRoomMemberListState().copy(isSearchActive = false),
aRoomMemberListState().copy(isSearchActive = true), aRoomMemberListState().copy(isSearchActive = true),
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"), aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
@ -65,6 +66,7 @@ internal fun aRoomMemberListState(
searchQuery = "", searchQuery = "",
searchResults = searchResults, searchResults = searchResults,
isSearchActive = false, isSearchActive = false,
canInvite = false,
eventSink = {} eventSink = {}
) )

View file

@ -56,6 +56,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -68,6 +69,7 @@ import io.element.android.libraries.ui.strings.R as StringR
fun RoomMemberListView( fun RoomMemberListView(
state: RoomMemberListState, state: RoomMemberListState,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onInvitePressed: () -> Unit,
onMemberSelected: (UserId) -> Unit, onMemberSelected: (UserId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -79,7 +81,11 @@ fun RoomMemberListView(
Scaffold( Scaffold(
topBar = { topBar = {
if (!state.isSearchActive) { if (!state.isSearchActive) {
RoomMemberListTopBar(onBackPressed = onBackPressed) RoomMemberListTopBar(
canInvite = state.canInvite,
onBackPressed = onBackPressed,
onInvitePressed = onInvitePressed,
)
} }
} }
) { padding -> ) { padding ->
@ -192,8 +198,10 @@ private fun RoomMemberListItem(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RoomMemberListTopBar( private fun RoomMemberListTopBar(
canInvite: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
onInvitePressed: () -> Unit = {},
) { ) {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
modifier = modifier, modifier = modifier,
@ -205,6 +213,19 @@ private fun RoomMemberListTopBar(
) )
}, },
navigationIcon = { BackButton(onClick = onBackPressed) }, navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
if (canInvite) {
TextButton(
modifier = Modifier.padding(horizontal = 8.dp),
onClick = onInvitePressed,
) {
Text(
text = stringResource(StringR.string.action_invite),
fontSize = 16.sp,
)
}
}
}
) )
} }
@ -252,6 +273,7 @@ private fun ContentToPreview(state: RoomMemberListState) {
RoomMemberListView( RoomMemberListView(
state = state, state = state,
onBackPressed = {}, onBackPressed = {},
onMemberSelected = {} onMemberSelected = {},
onInvitePressed = {},
) )
} }

View file

@ -6,9 +6,11 @@
</plurals> </plurals>
<string name="screen_room_details_already_a_member">"Already a member"</string> <string name="screen_room_details_already_a_member">"Already a member"</string>
<string name="screen_room_details_already_invited">"Already invited"</string> <string name="screen_room_details_already_invited">"Already invited"</string>
<string name="screen_room_details_edition_error">"An error occurred when updating the room details"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string> <string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string> <string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_share_room_title">"Share room"</string> <string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_room_details_updating_room">"Updating room…"</string>
<string name="screen_room_member_list_pending_header_title">"Pending"</string> <string name="screen_room_member_list_pending_header_title">"Pending"</string>
<string name="screen_room_member_list_room_members_header_title">"Room members"</string> <string name="screen_room_member_list_room_members_header_title">"Room members"</string>
<string name="screen_dm_details_block_alert_action">"Block"</string> <string name="screen_dm_details_block_alert_action">"Block"</string>

View file

@ -25,7 +25,6 @@ import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsType import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -33,7 +32,6 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
@ -101,18 +99,19 @@ class RoomDetailsPresenterTests {
room.givenRoomMembersState(MatrixRoomMembersState.Unknown) room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized) Truth.assertThat(initialState.memberCount).isEqualTo(Async.Uninitialized)
skipItems(1)
room.givenRoomMembersState(MatrixRoomMembersState.Pending(null)) room.givenRoomMembersState(MatrixRoomMembersState.Pending(null))
val loadingState = awaitItem() val loadingState = awaitItem()
Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null)) Truth.assertThat(loadingState.memberCount).isEqualTo(Async.Loading(null))
room.givenRoomMembersState(MatrixRoomMembersState.Error(error)) room.givenRoomMembersState(MatrixRoomMembersState.Error(error))
//skipItems(1) skipItems(1)
val failureState = awaitItem() val failureState = awaitItem()
Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null)) Truth.assertThat(failureState.memberCount).isEqualTo(Async.Failure(error, null))
room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers)) room.givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
//skipItems(1) skipItems(1)
val successState = awaitItem() val successState = awaitItem()
Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1)) Truth.assertThat(successState.memberCount).isEqualTo(Async.Success(1))
@ -166,6 +165,8 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
@ -182,6 +183,8 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
@ -198,6 +201,8 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem() val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic) Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
@ -214,6 +219,8 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -235,6 +242,8 @@ class RoomDetailsPresenterTests {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
val errorState = awaitItem() val errorState = awaitItem()
Truth.assertThat(errorState.error).isNotNull() Truth.assertThat(errorState.error).isNotNull()
@ -242,6 +251,50 @@ class RoomDetailsPresenterTests {
Truth.assertThat(awaitItem().error).isNull() Truth.assertThat(awaitItem().error).isNull()
} }
} }
@Test
fun `present - initial state when user can invite others to room`() = runTest {
val room = aMatrixRoom().apply {
givenCanInviteResult(Result.success(true))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// Initially false
Truth.assertThat(awaitItem().canInvite).isFalse()
// Then the asynchronous check completes and it becomes true
Truth.assertThat(awaitItem().canInvite).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state when user can not invite others to room`() = runTest {
val room = aMatrixRoom().apply {
givenCanInviteResult(Result.success(false))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(awaitItem().canInvite).isFalse()
}
}
@Test
fun `present - initial state when canInvite errors`() = runTest {
val room = aMatrixRoom().apply {
givenCanInviteResult(Result.failure(Throwable("Whoops")))
}
val presenter = aRoomDetailsPresenter(room)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(awaitItem().canInvite).isFalse()
}
}
} }
fun aMatrixClient( fun aMatrixClient(

View file

@ -0,0 +1,331 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.invite
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class RoomInviteMembersPresenterTest {
@Test
fun `present - initial state has no results and no search`() = runTest {
val presenter = RoomInviteMembersPresenter(
userRepository = FakeUserRepository(),
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.canInvite).isFalse()
assertThat(initialState.searchQuery).isEmpty()
skipItems(1)
}
}
@Test
fun `present - updates search active state`() = runTest {
val presenter = RoomInviteMembersPresenter(
userRepository = FakeUserRepository(),
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(true))
val resultState = awaitItem()
assertThat(resultState.isSearchActive).isTrue()
}
}
@Test
fun `present - performs search and handles no results`() = runTest {
val repository = FakeUserRepository()
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(emptyList())
skipItems(1)
val resultState = awaitItem()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
}
}
@Test
fun `present - performs search and handles user results`() = runTest {
val repository = FakeUserRepository()
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList())
skipItems(1)
val resultState = awaitItem()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
val expectedUsers = aMatrixUserList()
val users = resultState.searchResults.users()
expectedUsers.forEachIndexed { index, matrixUser ->
assertThat(users[index].matrixUser).isEqualTo(matrixUser)
assertThat(users[index].isAlreadyInvited).isFalse()
assertThat(users[index].isAlreadyJoined).isFalse()
assertThat(users[index].isSelected).isFalse()
}
}
}
@Test
fun `present - performs search and handles membership state of existing users`() = runTest {
val userList = aMatrixUserList()
val joinedUser = userList[0]
val invitedUser = userList[1]
val repository = FakeUserRepository()
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(
aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
)))
}),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList())
skipItems(1)
val resultState = awaitItem()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
val users = resultState.searchResults.users()
// The result that matches a user with JOINED membership is marked as such
val userWhoShouldBeJoined = users.find { it.matrixUser == joinedUser }
assertThat(userWhoShouldBeJoined).isNotNull()
assertThat(userWhoShouldBeJoined?.isAlreadyJoined).isTrue()
assertThat(userWhoShouldBeJoined?.isAlreadyInvited).isFalse()
// The result that matches a user with INVITED membership is marked as such
val userWhoShouldBeInvited = users.find { it.matrixUser == invitedUser }
assertThat(userWhoShouldBeInvited).isNotNull()
assertThat(userWhoShouldBeInvited?.isAlreadyJoined).isFalse()
assertThat(userWhoShouldBeInvited?.isAlreadyInvited).isTrue()
// All other users are neither joined nor invited
val otherUsers = users.minus(userWhoShouldBeInvited!!).minus(userWhoShouldBeJoined!!)
assertThat(otherUsers.none { it.isAlreadyInvited }).isTrue()
assertThat(otherUsers.none { it.isAlreadyJoined }).isTrue()
}
}
@Test
fun `present - toggle users updates selected user state`() = runTest {
val repository = FakeUserRepository()
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
// When we toggle a user not in the list, they are added
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser()))
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser())
// Toggling a different user also adds them
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser(id = A_USER_ID_2.value)))
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser(), aMatrixUser(id = A_USER_ID_2.value))
// Toggling the first user removes them
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(aMatrixUser()))
assertThat(awaitItem().selectedUsers).containsExactly(aMatrixUser(id = A_USER_ID_2.value))
}
}
@Test
fun `present - selected users appear as such in search results`() = runTest {
val repository = FakeUserRepository()
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
val selectedUser = aMatrixUser()
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(selectedUser))
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList() + selectedUser)
skipItems(2)
val resultState = awaitItem()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
val users = resultState.searchResults.users()
// The one user we have previously toggled is marked as selected
val shouldBeSelectedUser = users.find { it.matrixUser == selectedUser }
assertThat(shouldBeSelectedUser).isNotNull()
assertThat(shouldBeSelectedUser?.isSelected).isTrue()
// And no others are
val allOtherUsers = users.minus(shouldBeSelectedUser!!)
assertThat(allOtherUsers.none { it.isSelected }).isTrue()
}
}
@Test
fun `present - toggling a user updates existing search results`() = runTest {
val repository = FakeUserRepository()
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom()),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
val selectedUser = aMatrixUser()
// Given a query is made
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList() + selectedUser)
skipItems(2)
// And then a user is toggled
initialState.eventSink(RoomInviteMembersEvents.ToggleUser(selectedUser))
skipItems(1)
val resultState = awaitItem()
// The results are updated...
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
val users = resultState.searchResults.users()
// The one user we have now toggled is marked as selected
val shouldBeSelectedUser = users.find { it.matrixUser == selectedUser }
assertThat(shouldBeSelectedUser).isNotNull()
assertThat(shouldBeSelectedUser?.isSelected).isTrue()
// And no others are
val allOtherUsers = users.minus(shouldBeSelectedUser!!)
assertThat(allOtherUsers.none { it.isSelected }).isTrue()
}
}
private fun createDataSource(
matrixRoom: MatrixRoom = aMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
},
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
) = RoomMemberListDataSource(matrixRoom, coroutineDispatchers)
private fun SearchBarResultState<ImmutableList<InvitableUser>>.users() =
(this as? SearchBarResultState.Results<ImmutableList<InvitableUser>>)?.results.orEmpty()
}

View file

@ -32,6 +32,7 @@ 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
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -113,6 +114,54 @@ class RoomMemberListPresenterTests {
} }
} }
@Test
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
val presenter = createPresenter(
matrixRoom = FakeMatrixRoom().apply {
givenCanInviteResult(Result.success(true))
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val loadedState = awaitItem()
Truth.assertThat(loadedState.canInvite).isTrue()
}
}
@Test
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
val presenter = createPresenter(
matrixRoom = FakeMatrixRoom().apply {
givenCanInviteResult(Result.success(false))
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val loadedState = awaitItem()
Truth.assertThat(loadedState.canInvite).isFalse()
}
}
@Test
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
val presenter = createPresenter(
matrixRoom = FakeMatrixRoom().apply {
givenCanInviteResult(Result.failure(Throwable("Eek")))
}
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val loadedState = awaitItem()
Truth.assertThat(loadedState.canInvite).isFalse()
}
}
} }
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@ -125,6 +174,7 @@ private fun createDataSource(
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
private fun createPresenter( private fun createPresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom(),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(), roomMemberListDataSource: RoomMemberListDataSource = createDataSource(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers() coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
) = RoomMemberListPresenter(roomMemberListDataSource, coroutineDispatchers) ) = RoomMemberListPresenter(matrixRoom, roomMemberListDataSource, coroutineDispatchers)

View file

@ -118,7 +118,7 @@ test_hamcrest = "org.hamcrest:hamcrest:2.2"
test_orchestrator = "androidx.test:orchestrator:1.4.2" test_orchestrator = "androidx.test:orchestrator:1.4.2"
test_turbine = "app.cash.turbine:turbine:0.12.3" test_turbine = "app.cash.turbine:turbine:0.12.3"
test_truth = "com.google.truth:truth:1.1.3" test_truth = "com.google.truth:truth:1.1.3"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.11" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.12"
test_robolectric = "org.robolectric:robolectric:4.10.3" test_robolectric = "org.robolectric:robolectric:4.10.3"
test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" }
@ -133,7 +133,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.14" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.15"
sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }

View file

@ -57,6 +57,7 @@ fun <T> SearchBar(
placeHolderTitle: String, placeHolderTitle: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
showBackButton: Boolean = true,
resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(), resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
shape: Shape = SearchBarDefaults.inputFieldShape, shape: Shape = SearchBarDefaults.inputFieldShape,
tonalElevation: Dp = SearchBarDefaults.Elevation, tonalElevation: Dp = SearchBarDefaults.Elevation,
@ -87,7 +88,7 @@ fun <T> SearchBar(
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine) modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
) )
}, },
leadingIcon = if (active) { leadingIcon = if (showBackButton && active) {
{ BackButton(onClick = { onActiveChange(false) }) } { BackButton(onClick = { onActiveChange(false) }) }
} else { } else {
null null
@ -179,6 +180,16 @@ internal fun SearchBarPreviewActiveWithQuery() = ElementThemedPreview {
) )
} }
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewActiveWithQueryNoBackButton() = ElementThemedPreview {
ContentToPreview(
query = "search term",
active = true,
showBackButton = false,
)
}
@Preview(group = PreviewGroup.Search) @Preview(group = PreviewGroup.Search)
@Composable @Composable
internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview { internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview {
@ -212,6 +223,7 @@ internal fun SearchBarPreviewActiveWithContent() = ElementThemedPreview {
private fun ContentToPreview( private fun ContentToPreview(
query: String = "", query: String = "",
active: Boolean = false, active: Boolean = false,
showBackButton: Boolean = true,
resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(), resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(),
contentPrefix: @Composable ColumnScope.() -> Unit = {}, contentPrefix: @Composable ColumnScope.() -> Unit = {},
contentSuffix: @Composable ColumnScope.() -> Unit = {}, contentSuffix: @Composable ColumnScope.() -> Unit = {},
@ -221,6 +233,7 @@ private fun ContentToPreview(
query = query, query = query,
active = active, active = active,
resultState = resultState, resultState = resultState,
showBackButton = showBackButton,
onQueryChange = {}, onQueryChange = {},
onActiveChange = {}, onActiveChange = {},
placeHolderTitle = "Search for things", placeHolderTitle = "Search for things",

View file

@ -85,4 +85,8 @@ interface MatrixRoom : Closeable {
suspend fun acceptInvitation(): Result<Unit> suspend fun acceptInvitation(): Result<Unit>
suspend fun rejectInvitation(): Result<Unit> suspend fun rejectInvitation(): Result<Unit>
suspend fun inviteUserById(id: UserId): Result<Unit>
suspend fun canInvite(): Result<Boolean>
} }

View file

@ -19,7 +19,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
sealed interface EventSendState { sealed interface EventSendState {
object NotSendYet : EventSendState object NotSentYet : EventSendState
data class SendingFailed( data class SendingFailed(
val error: String val error: String

View file

@ -58,7 +58,6 @@ import org.matrix.rustcomponents.sdk.RequiredState
import org.matrix.rustcomponents.sdk.SlidingSyncList import org.matrix.rustcomponents.sdk.SlidingSyncList
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt import org.matrix.rustcomponents.sdk.SlidingSyncListOnceBuilt
import org.matrix.rustcomponents.sdk.SlidingSyncMode
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.use
@ -118,7 +117,7 @@ class RustMatrixClient constructor(
) )
) )
.filters(visibleRoomsSlidingSyncFilters) .filters(visibleRoomsSlidingSyncFilters)
.syncMode(mode = SlidingSyncMode.SELECTIVE) .syncModeSelective()
.addRange(0u, 20u) .addRange(0u, 20u)
.onceBuilt(object : SlidingSyncListOnceBuilt { .onceBuilt(object : SlidingSyncListOnceBuilt {
override fun updateList(list: SlidingSyncList): SlidingSyncList { override fun updateList(list: SlidingSyncList): SlidingSyncList {
@ -140,7 +139,7 @@ class RustMatrixClient constructor(
) )
) )
.filters(invitesSlidingSyncFilters) .filters(invitesSlidingSyncFilters)
.syncMode(mode = SlidingSyncMode.SELECTIVE) .syncModeSelective()
.addRange(0u, 20u) .addRange(0u, 20u)
.onceBuilt(object : SlidingSyncListOnceBuilt { .onceBuilt(object : SlidingSyncListOnceBuilt {
override fun updateList(list: SlidingSyncList): SlidingSyncList { override fun updateList(list: SlidingSyncList): SlidingSyncList {

View file

@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.SlidingSyncRoom import org.matrix.rustcomponents.sdk.SlidingSyncRoom
import org.matrix.rustcomponents.sdk.UpdateSummary import org.matrix.rustcomponents.sdk.UpdateSummary
import org.matrix.rustcomponents.sdk.genTransactionId import org.matrix.rustcomponents.sdk.genTransactionId
@ -209,6 +210,18 @@ class RustMatrixRoom(
} }
} }
override suspend fun inviteUserById(id: UserId): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.inviteUserById(id.value)
}
}
override suspend fun canInvite(): Result<Boolean> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.member(sessionId.value).use(RoomMember::canInvite)
}
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching { runCatching {
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map()) innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())

View file

@ -152,10 +152,12 @@ class RustMatrixTimeline(
RequiredState(key = "m.room.canonical_alias", value = ""), RequiredState(key = "m.room.canonical_alias", value = ""),
RequiredState(key = "m.room.topic", value = ""), RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.join_rules", value = ""), RequiredState(key = "m.room.join_rules", value = ""),
RequiredState(key = "m.room.power_levels", value = ""),
), ),
timelineLimit = null timelineLimit = null
) )
val result = slidingSyncRoom.subscribeAndAddTimelineListener(timelineListener, settings) listenerTokens += slidingSyncRoom.subscribeToRoom(settings)
val result = slidingSyncRoom.addTimelineListener(timelineListener)
launch { launch {
fetchMembers() fetchMembers()
} }

View file

@ -63,7 +63,7 @@ fun RustProfileDetails.map(): ProfileTimelineDetails {
fun RustEventSendState?.map(): EventSendState? { fun RustEventSendState?.map(): EventSendState? {
return when (this) { return when (this) {
null -> null null -> null
RustEventSendState.NotSendYet -> EventSendState.NotSendYet RustEventSendState.NotSentYet -> EventSendState.NotSentYet
is RustEventSendState.SendingFailed -> EventSendState.SendingFailed(error) is RustEventSendState.SendingFailed -> EventSendState.SendingFailed(error)
is RustEventSendState.Sent -> EventSendState.Sent(EventId(eventId)) is RustEventSendState.Sent -> EventSendState.Sent(EventId(eventId))
} }

View file

@ -60,6 +60,8 @@ class FakeMatrixRoom(
private var updateMembersResult: Result<Unit> = Result.success(Unit) private var updateMembersResult: Result<Unit> = Result.success(Unit)
private var acceptInviteResult = Result.success(Unit) private var acceptInviteResult = Result.success(Unit)
private var rejectInviteResult = Result.success(Unit) private var rejectInviteResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private var sendMediaResult = Result.success(Unit) private var sendMediaResult = Result.success(Unit)
var sendMediaCount = 0 var sendMediaCount = 0
private set private set
@ -70,6 +72,9 @@ class FakeMatrixRoom(
var isInviteRejected: Boolean = false var isInviteRejected: Boolean = false
private set private set
var invitedUserId: UserId? = null
private set
private var leaveRoomError: Throwable? = null private var leaveRoomError: Throwable? = null
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown) override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
@ -137,6 +142,15 @@ class FakeMatrixRoom(
return rejectInviteResult return rejectInviteResult
} }
override suspend fun inviteUserById(id: UserId): Result<Unit> {
invitedUserId = id
return inviteUserResult
}
override suspend fun canInvite(): Result<Boolean> {
return canInviteResult
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = fakeSendMedia() override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = fakeSendMedia()
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = fakeSendMedia() override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = fakeSendMedia()
@ -182,6 +196,14 @@ class FakeMatrixRoom(
rejectInviteResult = result rejectInviteResult = result
} }
fun givenInviteUserResult(result: Result<Unit>) {
inviteUserResult = result
}
fun givenCanInviteResult(result: Result<Boolean>) {
canInviteResult = result
}
fun givenIgnoreResult(result: Result<Unit>) { fun givenIgnoreResult(result: Result<Unit>) {
ignoreResult = result ignoreResult = result
} }

View file

@ -23,6 +23,7 @@
<string name="action_edit">"Edit"</string> <string name="action_edit">"Edit"</string>
<string name="action_enable">"Enable"</string> <string name="action_enable">"Enable"</string>
<string name="action_invite">"Invite"</string> <string name="action_invite">"Invite"</string>
<string name="action_invite_friends">"Invite friends"</string>
<string name="action_invite_friends_to_app">"Invite friends to %1$s"</string> <string name="action_invite_friends_to_app">"Invite friends to %1$s"</string>
<string name="action_invites_list">"Invites"</string> <string name="action_invites_list">"Invites"</string>
<string name="action_learn_more">"Learn more"</string> <string name="action_learn_more">"Learn more"</string>
@ -69,6 +70,7 @@
<string name="common_file">"File"</string> <string name="common_file">"File"</string>
<string name="common_gif">"GIF"</string> <string name="common_gif">"GIF"</string>
<string name="common_image">"Image"</string> <string name="common_image">"Image"</string>
<string name="common_leaving_room">"Leaving room"</string>
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string> <string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
<string name="common_loading">"Loading…"</string> <string name="common_loading">"Loading…"</string>
<string name="common_message">"Message"</string> <string name="common_message">"Message"</string>
@ -98,6 +100,8 @@
<string name="common_suggestions">"Suggestions"</string> <string name="common_suggestions">"Suggestions"</string>
<string name="common_topic">"Topic"</string> <string name="common_topic">"Topic"</string>
<string name="common_unable_to_decrypt">"Unable to decrypt"</string> <string name="common_unable_to_decrypt">"Unable to decrypt"</string>
<string name="common_unable_to_invite_message">"We were unable to successfully send invites to one or more users."</string>
<string name="common_unable_to_invite_title">"Unable to send invite(s)"</string>
<string name="common_unsupported_event">"Unsupported event"</string> <string name="common_unsupported_event">"Unsupported event"</string>
<string name="common_username">"Username"</string> <string name="common_username">"Username"</string>
<string name="common_verification_cancelled">"Verification cancelled"</string> <string name="common_verification_cancelled">"Verification cancelled"</string>
@ -118,6 +122,7 @@
<string name="error_failed_loading_messages">"Failed loading messages"</string> <string name="error_failed_loading_messages">"Failed loading messages"</string>
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string> <string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
<string name="error_unknown">"Sorry, an error occurred"</string> <string name="error_unknown">"Sorry, an error occurred"</string>
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string> <string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="leave_room_alert_empty_subtitle">"Are you sure that you want to leave this room? You are the only person here. If you leave, no one will be able to join in the future, including you."</string> <string name="leave_room_alert_empty_subtitle">"Are you sure that you want to leave this room? You are the only person here. If you leave, no one will be able to join in the future, including you."</string>
<string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite."</string> <string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite."</string>
@ -162,4 +167,4 @@
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string> <string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"here"</string> <string name="screen_analytics_settings_read_terms_content_link">"here"</string>
<string name="screen_report_content_block_user">"Block user"</string> <string name="screen_report_content_block_user">"Block user"</string>
</resources> </resources>

View file

@ -95,6 +95,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
fun DependencyHandlerScope.allServicesImpl() { fun DependencyHandlerScope.allServicesImpl() {
implementation(project(":services:analytics:noop")) implementation(project(":services:analytics:noop"))
implementation(project(":services:apperror:impl"))
implementation(project(":services:appnavstate:impl")) implementation(project(":services:appnavstate:impl"))
implementation(project(":services:toolbox:impl")) implementation(project(":services:toolbox:impl"))
} }

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.apperror.api"
}
dependencies {
implementation(libs.coroutines.core)
}

View file

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

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.services.apperror.api
fun aAppErrorState() = AppErrorState.Error(
title = "An error occurred",
body = "Something went wrong, and the details of that would go here.",
dismiss = {},
)

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.services.apperror.api
import kotlinx.coroutines.flow.StateFlow
interface AppErrorStateService {
val appErrorStateFlow: StateFlow<AppErrorState>
fun showError(title: String, body: String)
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
}
anvil {
generateDaggerFactories.set(true)
}
android {
namespace = "io.element.android.services.apperror.impl"
}
dependencies {
anvil(projects.anvilcodegen)
implementation(libs.dagger)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.anvilannotations)
implementation(libs.coroutines.core)
implementation(libs.androidx.corektx)
api(projects.services.apperror.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.turbine)
testImplementation(libs.test.truth)
ksp(libs.showkase.processor)
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.services.apperror.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.aAppErrorState
@Composable
fun AppErrorView(
state: AppErrorState,
) {
if (state is AppErrorState.Error) {
AppErrorViewContent(
title = state.title,
body = state.body,
onDismiss = state.dismiss,
)
}
}
@Composable
fun AppErrorViewContent(
title: String,
body: String,
onDismiss: () -> Unit = { },
) {
ErrorDialog(
title = title,
content = body,
onDismiss = onDismiss,
)
}
@Preview
@Composable
internal fun AppErrorViewLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun AppErrorViewDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AppErrorView(
state = aAppErrorState()
)
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.services.apperror.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultAppErrorStateService @Inject constructor() : AppErrorStateService {
private val currentAppErrorState = MutableStateFlow<AppErrorState>(AppErrorState.NoError)
override val appErrorStateFlow: StateFlow<AppErrorState> = currentAppErrorState
override fun showError(title: String, body: String) {
currentAppErrorState.value = AppErrorState.Error(
title = title,
body = body,
dismiss = {
currentAppErrorState.value = AppErrorState.NoError
},
)
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.services.apperror.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.services.apperror.api.AppErrorState
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class DefaultAppErrorStateServiceTest {
@Test
fun `initial value is no error`() = runTest {
val service = DefaultAppErrorStateService()
service.appErrorStateFlow.test {
val state = awaitItem()
assertThat(state).isInstanceOf(AppErrorState.NoError::class.java)
}
}
@Test
fun `showError - emits value`() = runTest {
val service = DefaultAppErrorStateService()
service.appErrorStateFlow.test {
skipItems(1)
service.showError("Title", "Body")
val state = awaitItem()
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
val errorState = state as AppErrorState.Error
assertThat(errorState.title).isEqualTo("Title")
assertThat(errorState.body).isEqualTo("Body")
}
}
@Test
fun `dismiss - clears value`() = runTest {
val service = DefaultAppErrorStateService()
service.appErrorStateFlow.test {
skipItems(1)
service.showError("Title", "Body")
val state = awaitItem()
assertThat(state).isInstanceOf(AppErrorState.Error::class.java)
val errorState = state as AppErrorState.Error
errorState.dismiss()
assertThat(awaitItem()).isInstanceOf(AppErrorState.NoError::class.java)
}
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:56148beb26b35c8309190271b43fc225e11b90b8b849a8e60abf98b6ab663c1b oid sha256:439a2d9e9497486e86b63fe42402f1c2ee33b15695e47312e18867530d041dad
size 101010 size 96539

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9fa60afaf7ab23f66622d7d77e168db8b9d8bc15d178e8adb63f944de3cc29df oid sha256:255b99dea5c1da9d466533d510d7c0abe39a79cdc42b579cfa186176e9c6a49d
size 96242 size 91991

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:516e972069292f31625847ca39de06ea402ce1606c97d84f930c18eaa2448cfa
size 13759

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b387f10903926ba9ed30b7d213314388eebf23743cbbdbcf4b613cf3136f64aa
size 41524

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0af8c7149fd4266c68c0e4b8b9a699bc0b3fc0b05fea3f2e2544abc884f70c5b
size 11988

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8eb2fcdb57b7f1ba28990f9576fad6dce156fe0c5c9ae491797040fc22ae4f7b
size 39516

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e4d3bc53e6a86957218247b614be6bbc78cfb35ce49565f2243f50d6ef94fe3
size 14216

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:213777e4ace6f9c33209db37bffff2ca6dba13e540e9d07be82239d261ee8e3f
size 63951

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8ddec11fd636d345c49cf981ce7498ac2d94027d4b223b257b64288bb0254766
size 13328

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da5ba4637d999c8654e38a86ad0b26c75a6fe10a6988eb821b24cf91da2274e5
size 39070

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c57c20fe79d0dbb0ca25571fa2ab3ba6e06f3a9c6634e96fac3ed0155c2ea106
size 11160

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8e43279132a7536516cf267599f877f7c729f76be51d6319944f74c57af0e282
size 36493

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:933c5ac4478699e9e713af559025ab675a8bdc93b15c42ff27fe5525fa3b668a
size 13166

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a66a39bf18ba35abfc87dea22ca019eea98727bf246142cb034649c5b1772bd
size 61041

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:49e7533e6afba903219942193e0cc5cfbe2f67ba3b57516f77c259e7c42e8e3f oid sha256:bbe60787ea821d25168d4a6d75000629c9c0d505a2c8eec9ba3af57790bf4b0a
size 11813 size 12863

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:89ea65099fb4981bbeb24e49afb7400b0e8da79e3b783e198519ba1e970404a8 oid sha256:49e7533e6afba903219942193e0cc5cfbe2f67ba3b57516f77c259e7c42e8e3f
size 8317 size 11813

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745 oid sha256:89ea65099fb4981bbeb24e49afb7400b0e8da79e3b783e198519ba1e970404a8
size 7733 size 8317

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:070c93168058fded5a76e49050f6c4554c7fe483a19aae13c1af4426a6b575f1 oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745
size 30582 size 7733

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d5b879b74654fdad0638f432a71adfc9186fbe00dafd5d963a3affb33ec8c5c8 oid sha256:070c93168058fded5a76e49050f6c4554c7fe483a19aae13c1af4426a6b575f1
size 12841 size 30582

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5b879b74654fdad0638f432a71adfc9186fbe00dafd5d963a3affb33ec8c5c8
size 12841

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9c3abebbe9e55706af5f4d1089e4308b0e975258a9d6a0e1793a4208b36c9c93 oid sha256:925e60112749ce49cf1c2d855406860375357e4717cb7487d3b8bb9a94d7e816
size 11754 size 12882

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b8de2f28cf9918a7eddcc1311c34ba0376242a4708724b57689df000b7480524 oid sha256:9c3abebbe9e55706af5f4d1089e4308b0e975258a9d6a0e1793a4208b36c9c93
size 8197 size 11754

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7 oid sha256:b8de2f28cf9918a7eddcc1311c34ba0376242a4708724b57689df000b7480524
size 7514 size 8197

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9dd67135f57c6e8a336e238384b02cd6068770b84f429f357d03d5e1e4c22ab6 oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7
size 29286 size 7514

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:534cba0c13aa52b3557ab8df854c521dd13b82e5a1550d73abcef12925e389da oid sha256:9dd67135f57c6e8a336e238384b02cd6068770b84f429f357d03d5e1e4c22ab6
size 11878 size 29286

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:534cba0c13aa52b3557ab8df854c521dd13b82e5a1550d73abcef12925e389da
size 11878

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6a755d1793d5a77244abe66b8a72521d64c46c102e9e9c60d1839d793301526d oid sha256:72f159556f6ccee83c65589b882b3ac9ca9d4f4de8e611f31e19a524e22eab8e
size 39373 size 37569

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:04ffb414eeb75e4fb4187777be04c3fc3a9a0967c81a2150ea2d4f58ef4212bc oid sha256:6b1ac0c5e9fc3ecb9870ed3322418c26e26ec0873806d3eb7a89b2a51b39c1f1
size 30599 size 28563

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a5ccb63879a3b9a4d37a38903a5b81cf1a185afc638f2d0260b6a6fe68aa2419 oid sha256:aa8e818a0c6175cc40b888f8c5a093655b8a352cc2b90e7159d9317de8f68e8f
size 28779 size 26778

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a6b5488817b1262bc17800567b3634861230f6db3b81fbe1e91f26d6795a44ae oid sha256:54b897538574ecb5a6e7a13b0f3ae8d0495e8db4667f1878dfc0ca354d0faf47
size 36828 size 34903

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0d1d7c27b6c1b3ca495fe794ca888e55f013b8c99b716f4571f8c3c278ed85d7 oid sha256:1d5ae33b866c962c0a444b6893dc602b99943a2305a3c3e9695219c1a33388bb
size 38995 size 37208

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a755d1793d5a77244abe66b8a72521d64c46c102e9e9c60d1839d793301526d
size 39373

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:333798d610045630944e53c4c43bc401781cc5499ad8c085037239a93de6eccc oid sha256:8912f6213b14f6380359582e96901ee5a1dcdf9f939de3ac8d5bbb6a62f25eaa
size 36950 size 35190

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0206a86b0db1d4c2a984435fda5b70335fa3f005cc4c49a3da7d1f3f80fb04e6 oid sha256:ad68e0852e7742b58d976a1d8ca2e2da66bf5477c0876ecc9e2c1007cf4b843f
size 29057 size 27197

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d55124590cfc79d4c85a35de16223ff7b7821c32742670ea12af0bea05a6ef87 oid sha256:048309d5c82ff3ea6dcdd1e5bc81103b5d9147083089f7a006fce840b1d23867
size 27081 size 25445

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