Merge branch 'develop' into feature/fga/image_loading
This commit is contained in:
commit
922b63e746
104 changed files with 1791 additions and 114 deletions
2
.github/workflows/maestro.yml
vendored
2
.github/workflows/maestro.yml
vendored
|
|
@ -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
43
.github/workflows/nightlyReports.yml
vendored
Normal 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
|
||||||
16
.github/workflows/tests.yml
vendored
16
.github/workflows/tests.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
1
changelog.d/245.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[Create and join rooms] Add ability to invite users to existing rooms
|
||||||
1
changelog.d/420.doc
Normal file
1
changelog.d/420.doc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Update design documentation to add references to Compound
|
||||||
1
changelog.d/437.feature
Normal file
1
changelog.d/437.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Handle "Invite people" action in the start a chat flow
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
services/apperror/api/build.gradle.kts
Normal file
27
services/apperror/api/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
51
services/apperror/impl/build.gradle.kts
Normal file
51
services/apperror/impl/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:516e972069292f31625847ca39de06ea402ce1606c97d84f930c18eaa2448cfa
|
||||||
|
size 13759
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b387f10903926ba9ed30b7d213314388eebf23743cbbdbcf4b613cf3136f64aa
|
||||||
|
size 41524
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0af8c7149fd4266c68c0e4b8b9a699bc0b3fc0b05fea3f2e2544abc884f70c5b
|
||||||
|
size 11988
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8eb2fcdb57b7f1ba28990f9576fad6dce156fe0c5c9ae491797040fc22ae4f7b
|
||||||
|
size 39516
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1e4d3bc53e6a86957218247b614be6bbc78cfb35ce49565f2243f50d6ef94fe3
|
||||||
|
size 14216
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:213777e4ace6f9c33209db37bffff2ca6dba13e540e9d07be82239d261ee8e3f
|
||||||
|
size 63951
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8ddec11fd636d345c49cf981ce7498ac2d94027d4b223b257b64288bb0254766
|
||||||
|
size 13328
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:da5ba4637d999c8654e38a86ad0b26c75a6fe10a6988eb821b24cf91da2274e5
|
||||||
|
size 39070
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c57c20fe79d0dbb0ca25571fa2ab3ba6e06f3a9c6634e96fac3ed0155c2ea106
|
||||||
|
size 11160
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8e43279132a7536516cf267599f877f7c729f76be51d6319944f74c57af0e282
|
||||||
|
size 36493
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:933c5ac4478699e9e713af559025ab675a8bdc93b15c42ff27fe5525fa3b668a
|
||||||
|
size 13166
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3a66a39bf18ba35abfc87dea22ca019eea98727bf246142cb034649c5b1772bd
|
||||||
|
size 61041
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d5b879b74654fdad0638f432a71adfc9186fbe00dafd5d963a3affb33ec8c5c8
|
||||||
|
size 12841
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:534cba0c13aa52b3557ab8df854c521dd13b82e5a1550d73abcef12925e389da
|
||||||
|
size 11878
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6a755d1793d5a77244abe66b8a72521d64c46c102e9e9c60d1839d793301526d
|
||||||
|
size 39373
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue