Merge remote-tracking branch 'origin/develop' into local-sdk-fixes

This commit is contained in:
Chris Smith 2023-04-11 09:07:13 +01:00
commit b98b280a3c
343 changed files with 13218 additions and 426 deletions

View file

@ -28,11 +28,6 @@ jobs:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2
with:
@ -47,12 +42,14 @@ jobs:
app/build/outputs/apk/debug/*.apk
- uses: rnkdsh/action-upload-diawi@v1.3.2
id: diawi
if: ${{ github.event_name == 'pull_request' }}
with:
env:
token: ${{ secrets.DIAWI_TOKEN }}
if: ${{ github.event_name == 'pull_request' && env.token != '' }}
with:
token: ${{ env.token }}
file: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
- name: Add or update PR comment with QR Code to download APK.
if: ${{ github.event_name == 'pull_request' }}
if: ${{ github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }}
uses: NejcZdovc/comment-pr@v2
with:
message: |

View file

@ -11,7 +11,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
uses: danger/danger-js@11.2.4
uses: danger/danger-js@11.2.5
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:

View file

@ -24,11 +24,6 @@ jobs:
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
name: Use JDK 17
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Assemble debug APK
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
- uses: mobile-dev-inc/action-maestro-cloud@v1.3.1

View file

@ -13,13 +13,9 @@ jobs:
nightly:
name: Build and publish nightly APK to Firebase
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: Install towncrier
run: |
python3 -m pip install towncrier

View file

@ -13,11 +13,6 @@ jobs:
runs-on: ubuntu-latest
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: Install towncrier
run: |
python3 -m pip install towncrier

View file

@ -8,7 +8,7 @@ on:
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxPermSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
jobs:
@ -21,11 +21,6 @@ jobs:
cancel-in-progress: true
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: Configure gradle
uses: gradle/gradle-build-action@v2
with:
@ -47,7 +42,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
uses: danger/danger-js@11.2.4
uses: danger/danger-js@11.2.5
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:
@ -65,11 +60,6 @@ jobs:
cancel-in-progress: true
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: Configure gradle
uses: gradle/gradle-build-action@v2
with:

33
.github/workflows/sync-localazy.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: Sync Localazy
on:
schedule:
# At 00:00 on every Monday UTC
- cron: '0 0 * * 1'
jobs:
sync-localazy:
runs-on: ubuntu-latest
# Skip in forks
if: github.repository == 'vector-im/element-x-android'
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Setup Localazy
run: |
curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/localazy.gpg] https://maven.localazy.com/repository/apt/ stable main" | sudo tee /etc/apt/sources.list.d/localazy.list
sudo apt-get update && sudo apt-get install localazy
- name: Run Localazy script
run: ./tools/localazy/downloadStrings.sh --all
- name: Create Pull Request for Strings
uses: peter-evans/create-pull-request@v5
with:
commit-message: Sync Strings from Localazy
title: Sync Strings
body: |
- Update Strings from Localazy
branch: sync-localazy
base: develop

View file

@ -25,11 +25,6 @@ jobs:
uses: actions/checkout@v3
with:
lfs: 'true'
- name: ☕️ Use JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2
with:

View file

@ -5,7 +5,9 @@
* [Contributing code to Matrix](#contributing-code-to-matrix)
* [Android Studio settings](#android-studio-settings)
* [Compilation](#compilation)
* [I want to help translating Element](#i-want-to-help-translating-element)
* [Strings](#strings)
* [I want to add new strings to the project](#i-want-to-add-new-strings-to-the-project)
* [I want to help translating Element](#i-want-to-help-translating-element)
* [I want to submit a PR to fix an issue](#i-want-to-submit-a-pr-to-fix-an-issue)
* [Kotlin](#kotlin)
* [Changelog](#changelog)
@ -15,7 +17,6 @@
* [lint](#lint)
* [Unit tests](#unit-tests)
* [Tests](#tests)
* [Internationalisation](#internationalisation)
* [Accessibility](#accessibility)
* [Jetpack Compose](#jetpack-compose)
* [Authors](#authors)
@ -40,11 +41,28 @@ Please ensure that you're using the project formatting rules (which are in the p
This project should compile without any special action. Just clone it and open it with Android Studio, or compile from command line using `gradlew`.
## I want to help translating Element
Note: please make sure that the configuration is `app` and not `samples.minimal`.
For now strings are coming from Element Android project, so:
- If you want to fix an issue with an English string, please submit a PR on Element Android.
- If you want to fix an issue in other languages, or add a missing translation, or even add a new language, please use [Weblate](https://translate.element.io/projects/element-android/).
## Strings
The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with ElementX iOS.
### I want to add new strings to the project
Only the core team can modify or add English strings to Localazy. As an external contributor, if you want to add new strings, feel free to add an Android resource file to the project (for instance a file named `temporary.xml`), with a note in the description of the PR for the reviewer to integrate the String into `Localazy`. If accepted, the reviewer will add the String(s) for you, and then you can download them on your branch (following these [instructions](./tools/localazy/README.md#download-translations)) and remove the temporary file.
Please follow the naming rules for the key. More details in [the dedicated section in this README.md](./tools/localazy/README.md#key-naming-rules)
### I want to help translating Element
Please note that the Localazy project is not open yet for external contributions.
To help translating, please go to [https://localazy.com/p/element](https://localazy.com/p/element).
- If you want to fix an issue with an English string, please open an issue on the github project of ElementX (Android or iOS). Only the core team can modify or add English strings.
- If you want to fix an issue in other languages, or add a missing translation, or even add a new language, please go to [https://localazy.com/p/element](https://localazy.com/p/element).
More informations can be found [in this README.md](./tools/localazy/README.md).
## I want to submit a PR to fix an issue
@ -135,10 +153,6 @@ Also, if possible, please test your change on a real device. Testing on Android
You should consider adding Unit tests with your PR, and also integration tests (AndroidTest). Please refer to [this document](./docs/integration_tests.md) to install and run the integration test environment.
### Internationalisation
For now strings are coming from Element Android project, so please read [the documentation](https://github.com/vector-im/element-android/blob/develop/CONTRIBUTING.md#internationalisation) from there.
### Accessibility
Please consider accessibility as an important point. As a minimum requirement, in layout XML files please use attributes such as `android:contentDescription` and `android:importantForAccessibility`, and test with a screen reader if it's working well. You can add new string resources, dedicated to accessibility, in this case, please prefix theirs id with `a11y_`.

View file

@ -25,7 +25,7 @@ dependencies {
implementation(projects.anvilannotations)
api(libs.anvil.compiler.api)
implementation(libs.anvil.compiler.utils)
implementation("com.squareup:kotlinpoet:1.12.0")
implementation("com.squareup:kotlinpoet:1.13.0")
implementation(libs.dagger)
compileOnly("com.google.auto.service:auto-service-annotations:1.0.1")
kapt("com.google.auto.service:auto-service:1.0.1")

View file

@ -25,6 +25,7 @@ import extension.allServicesImpl
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-application")
alias(libs.plugins.stem)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
@ -32,6 +33,7 @@ plugins {
id("com.google.firebase.appdistribution") version "4.0.0"
id("org.jetbrains.kotlinx.knit") version "0.4.0"
id("kotlin-parcelize")
id("com.google.gms.google-services")
}
android {
@ -139,7 +141,7 @@ android {
}
}
kotlinOptions {
jvmTarget = "17"
jvmTarget = "1.8"
}
// Waiting for https://github.com/google/ksp/issues/37
@ -150,10 +152,6 @@ android {
}
}
}
buildFeatures {
buildConfig = true
}
}
androidComponents {
@ -216,15 +214,20 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
implementation(libs.appyx.core)
implementation(libs.androidx.splash)
implementation(libs.androidx.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup)
implementation(libs.androidx.preference)
implementation(libs.coil)
implementation(platform(libs.network.okhttp.bom))
implementation("com.squareup.okhttp3:logging-interceptor")
implementation(platform(libs.google.firebase.bom))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation(libs.dagger)
kapt(libs.dagger.compiler)

View file

@ -0,0 +1,49 @@
{
"project_info": {
"project_number": "912726360885",
"firebase_url": "https://vector-alpha.firebaseio.com",
"project_id": "vector-alpha",
"storage_bucket": "vector-alpha.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:912726360885:android:def0a4e454042e9b00427c",
"android_client_info": {
"package_name": "io.element.android.x.debug"
}
},
"oauth_client": [
{
"client_id": "912726360885-hvgoj23p6plt7hikhtdrakihojghaftv.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "io.element.android.x.debug",
"certificate_hash": "41bd63b3b612a15d9ba36a5245c393f2a9b992d1"
}
},
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
@ -31,9 +30,10 @@
tools:targetApi="33">
<activity
android:name=".MainActivity"
android:theme="@style/Theme.ElementX.Splash"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:launchMode="singleInstance"
android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -16,6 +16,7 @@
package io.element.android.x
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
@ -30,6 +31,7 @@ import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.x.di.AppBindings
import timber.log.Timber
class MainActivity : NodeComponentActivity() {
@ -52,6 +54,17 @@ class MainActivity : NodeComponentActivity() {
}
}
/**
* Called when:
* - the launcher icon is clicked (if the app is already running);
* - a notification is clicked.
* - the app is going to background (<- this is strange)
*/
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
Timber.w("onNewIntent")
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
bindings<AppBindings>().matrixClientsHolder().onSaveInstanceState(outState)

View file

@ -17,13 +17,18 @@
package io.element.android.x.di
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Resources
import androidx.preference.PreferenceManager
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.di.SingleIn
import io.element.android.x.BuildConfig
import io.element.android.x.R
@ -46,6 +51,11 @@ object AppModule {
return File(context.filesDir, "sessions")
}
@Provides
fun providesResources(@ApplicationContext context: Context): Resources {
return context.resources
}
@Provides
@SingleIn(AppScope::class)
fun providesAppCoroutineScope(): CoroutineScope {
@ -68,6 +78,13 @@ object AppModule {
okHttpLoggingLevel = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC,
)
@Provides
@SingleIn(AppScope::class)
@DefaultPreferences
fun providesDefaultSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(context)
}
@Provides
@SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers {
@ -78,4 +95,10 @@ object AppModule {
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
}
@Provides
@SingleIn(AppScope::class)
fun provideSnackbarDispatcher(): SnackbarDispatcher {
return SnackbarDispatcher()
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.intent
import android.content.Context
import android.content.Intent
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.x.MainActivity
import javax.inject.Inject
// TODO EAx change to deep-link.
@ContributesBinding(AppScope::class)
class IntentProviderImpl @Inject constructor(
@ApplicationContext private val context: Context,
) : IntentProvider {
override fun getMainIntent(): Intent {
return Intent(context, MainActivity::class.java)
}
override fun getIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): Intent {
// TODO Handle deeplink or pass parameters
return Intent(context, MainActivity::class.java)
}
}

View file

@ -37,4 +37,4 @@
}
],
"configuration_version": "1"
}
}

View file

@ -0,0 +1,40 @@
{
"project_info": {
"project_number": "912726360885",
"firebase_url": "https://vector-alpha.firebaseio.com",
"project_id": "vector-alpha",
"storage_bucket": "vector-alpha.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:912726360885:android:d097de99a4c23d2700427c",
"android_client_info": {
"package_name": "io.element.android.x"
}
},
"oauth_client": [
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View file

@ -41,10 +41,16 @@ dependencies {
allFeaturesApi(rootDir)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.features.verifysession.api)
implementation(projects.features.roomdetails.api)
implementation(projects.tests.uitests)

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appnav
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.ui.strings.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
class LoggedInEventProcessor @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver,
sessionVerificationService: SessionVerificationService,
) {
private var observingJob: Job? = null
private val displayLeftRoomMessage = roomMembershipObserver.updates
.map { !it.isUserInRoom }
private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState
.map { it == VerificationFlowState.Finished }
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = coroutineScope.launch {
displayLeftRoomMessage.onEach {
displayMessage(R.string.common_current_user_left_room)
}.launchIn(this)
displayVerificationSuccessfulMessage
.drop(1)
.onEach {
displayMessage(R.string.common_verification_complete)
}.launchIn(this)
}
}
fun stopObserving() {
observingJob?.cancel()
observingJob = null
}
private suspend fun displayMessage(message: Int) {
snackbarDispatcher.post(SnackbarMessage(message))
}
}

View file

@ -32,9 +32,11 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
@ -46,13 +48,18 @@ import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
import kotlin.coroutines.coroutineContext
@ContributesNode(AppScope::class)
class LoggedInFlowNode @AssistedInject constructor(
@ -63,6 +70,8 @@ class LoggedInFlowNode @AssistedInject constructor(
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val coroutineScope: CoroutineScope,
snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.RoomList,
@ -87,6 +96,11 @@ class LoggedInFlowNode @AssistedInject constructor(
) : NodeInputs
private val inputs: Inputs = inputs()
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher,
inputs.matrixClient.roomMembershipObserver(),
inputs.matrixClient.sessionVerificationService(),
)
override fun onBuilt() {
super.onBuilt()
@ -99,6 +113,7 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToSession(inputs.matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope)
},
onDestroy = {
val imageLoaderFactory = bindings<MatrixUIBindings>().notLoggedInImageLoaderFactory()
@ -106,11 +121,15 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins<LifecycleCallback>().forEach { it.onFlowReleased(inputs.matrixClient) }
appNavigationStateService.onLeavingSpace()
appNavigationStateService.onLeavingSession()
loggedInFlowProcessor.stopObserving()
}
)
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Permanent : NavTarget
@Parcelize
object RoomList : NavTarget
@ -129,6 +148,9 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Permanent -> {
createNode<LoggedInNode>(buildContext)
}
NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback {
override fun onRoomClicked(roomId: RoomId) {
@ -178,7 +200,16 @@ class LoggedInFlowNode @AssistedInject constructor(
.build()
}
NavTarget.CreateRoom -> {
createRoomEntryPoint.createNode(this, buildContext)
val callback = object : CreateRoomEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) {
backstack.replace(NavTarget.Room(roomId))
}
}
createRoomEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
NavTarget.VerifySession -> {
verifySessionEntryPoint.createNode(this, buildContext)
@ -188,11 +219,15 @@ class LoggedInFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
// Animate navigation to settings and to a room
transitionHandler = rememberDefaultTransitionHandler(),
)
Box(modifier = modifier) {
Children(
navModel = backstack,
modifier = Modifier,
// Animate navigation to settings and to a room
transitionHandler = rememberDefaultTransitionHandler(),
)
PermanentChild(navTarget = NavTarget.Permanent)
}
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
@ -38,7 +39,12 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ -49,6 +55,8 @@ class RoomFlowNode @AssistedInject constructor(
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
roomMembershipObserver: RoomMembershipObserver,
coroutineScope: CoroutineScope,
) : BackstackNode<RoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
@ -68,6 +76,9 @@ class RoomFlowNode @AssistedInject constructor(
) : NodeInputs
private val inputs: Inputs = inputs()
private val timeline = inputs.room.timeline()
private val roomFlowPresenter = RoomFlowPresenter(inputs.room)
init {
lifecycle.subscribe(
@ -83,6 +94,13 @@ class RoomFlowNode @AssistedInject constructor(
appNavigationStateService.onLeavingRoom()
}
)
roomMembershipObserver.updates
.filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom }
.onEach {
navigateUp()
}
.launchIn(coroutineScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -95,7 +113,7 @@ class RoomFlowNode @AssistedInject constructor(
})
}
NavTarget.RoomDetails -> {
roomDetailsEntryPoint.createNode(this, buildContext)
roomDetailsEntryPoint.createNode(this, buildContext, emptyList())
}
}
}
@ -110,6 +128,7 @@ class RoomFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
roomFlowPresenter.present()
Children(
navModel = backstack,
modifier = modifier,

View file

@ -0,0 +1,46 @@
/*
* 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.appnav
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import timber.log.Timber
class RoomFlowPresenter(
private val room: MatrixRoom,
) : Presenter<RoomFlowState> {
@Composable
override fun present(): RoomFlowState {
// Preload room members so we can quickly detect if the room is a DM room
LaunchedEffect(Unit) {
room.fetchMembers()
.onFailure {
Timber.e(it, "Fail to fetch members for room ${room.roomId}")
}.onSuccess {
Timber.v("Success fetching members for room ${room.roomId}")
}
}
return RoomFlowState
}
}
// At first the return type was Unit, but detekt complained about it
object RoomFlowState

View file

@ -0,0 +1,21 @@
/*
* 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.appnav.loggedin
// sealed interface LoggedInEvents {
// object MyEvent : LoggedInEvents
// }

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appnav.loggedin
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.di.SessionScope
@ContributesNode(SessionScope::class)
class LoggedInNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val loggedInPresenter: LoggedInPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val loggedInState = loggedInPresenter.present()
LoggedInView(
state = loggedInState,
modifier = modifier
)
}
}

View file

@ -0,0 +1,64 @@
/*
* 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.appnav.loggedin
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import javax.inject.Inject
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
private val pushService: PushService,
) : Presenter<LoggedInState> {
private val postNotificationPermissionsPresenter by lazy {
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
} else {
NoopPermissionsPresenter()
}
}
@Composable
override fun present(): LoggedInState {
LaunchedEffect(Unit) {
// Ensure pusher is registered
pushService.registerFirebasePusher(matrixClient)
}
val permissionsState = postNotificationPermissionsPresenter.present()
// fun handleEvents(event: LoggedInEvents) {
// when (event) {
// }
// }
return LoggedInState(
permissionsState = permissionsState,
// eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.appnav.loggedin
import io.element.android.libraries.permissions.api.PermissionsState
data class LoggedInState(
val permissionsState: PermissionsState,
// val eventSink: (LoggedInEvents) -> Unit
)

View file

@ -0,0 +1,33 @@
/*
* 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.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
get() = sequenceOf(
aLoggedInState(),
// Add other state here
)
}
fun aLoggedInState() = LoggedInState(
permissionsState = createDummyPostNotificationPermissionsState(),
// eventSink = {}
)

View file

@ -0,0 +1,61 @@
/*
* 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.appnav.loggedin
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.permissions.api.PermissionsView
@Composable
fun LoggedInView(
state: LoggedInState,
modifier: Modifier = Modifier
) {
val activity = LocalContext.current as? Activity
PermissionsView(
state = state.permissionsState,
modifier = modifier,
openSystemSettings = {
activity?.let { openAppSettingsPage(it, "") }
}
)
}
@Preview
@Composable
fun LoggedInViewLightPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun LoggedInViewDarkPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: LoggedInState) {
LoggedInView(
state = state
)
}

View file

@ -0,0 +1,62 @@
/*
* 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.appnav
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.lang.IllegalStateException
class RoomFlowPresenterTest {
@Test
fun `present - fetches room members`() = runTest {
val fakeTimeline = FakeMatrixTimeline()
val room = FakeMatrixRoom(matrixTimeline = fakeTimeline)
val presenter = RoomFlowPresenter(room)
Truth.assertThat(room.areMembersFetched).isFalse()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(room.areMembersFetched).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - recovers from error while fetching room members`() = runTest {
val fakeTimeline = FakeMatrixTimeline()
val room = FakeMatrixRoom(matrixTimeline = fakeTimeline).apply {
givenFetchMemberResult(Result.failure(IllegalStateException("Some error")))
}
val presenter = RoomFlowPresenter(room)
Truth.assertThat(room.areMembersFetched).isFalse()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
Truth.assertThat(room.areMembersFetched).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.appnav.loggedin
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.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoggedInPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.permissionsState.permission).isEmpty()
}
}
private fun createPresenter(): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(),
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return NoopPermissionsPresenter()
}
},
pushService = object : PushService {
override fun notificationStyleChanged() {
}
override suspend fun registerFirebasePusher(matrixClient: MatrixClient) {
}
override suspend fun testPush() {
}
}
)
}
}

View file

@ -4,6 +4,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10")
classpath("com.google.gms:google-services:4.3.15")
}
}
@ -230,6 +231,8 @@ koverMerged {
overrideClassFilter {
includes += "*Presenter"
excludes += "*TemplatePresenter"
excludes += "*Fake*Presenter"
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
}
bound {
minValue = 90
@ -246,6 +249,7 @@ koverMerged {
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
}
bound {
minValue = 90

View file

@ -1 +1,2 @@
Implement Room Details screen
Implement Room Member List screen

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

@ -0,0 +1 @@
Add leave room functionality to the Room Details screen.

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

@ -0,0 +1 @@
[Create and join rooms] Show or create direct message room

View file

@ -251,7 +251,8 @@ Main libraries and frameworks used in this application:
- Navigation state with [Appyx](https://bumble-tech.github.io/appyx/). Please
watch [this video](https://www.droidcon.com/2022/11/15/model-driven-navigation-with-appyx-from-zero-to-hero/) to learn more about Appyx!
- DI: [Dagger](https://dagger.dev/) and [Anvil](https://github.com/square/anvil)
- DI: [Dagger](https://dagger.dev/) and [Anvil](https://github.com/square/anvil). Please
watch [this video](https://www.droidcon.com/2022/06/28/dagger-anvil-learning-to-love-dependency-injection/) to learn more about Anvil!
- Reactive State management with Compose runtime and [Molecule](https://github.com/cashapp/molecule)
Some patterns are inspired by [Circuit](https://slackhq.github.io/circuit/)

View file

@ -24,4 +24,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View file

@ -16,6 +16,21 @@
package io.element.android.features.createroom.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : SimpleFeatureEntryPoint
interface CreateRoomEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId)
}
}

View file

@ -47,7 +47,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.features.selectusers.api)
implementation(projects.features.userlist.api)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)
@ -56,7 +56,8 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.selectusers.impl)
testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
androidTestImplementation(libs.test.junitext)

View file

@ -0,0 +1,33 @@
/*
* 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.createroom.impl
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import javax.inject.Inject
// TODO this is empty as we currently don't have an endpoint to perform user search
class AllMatrixUsersDataSource @Inject constructor() : MatrixUserDataSource {
override suspend fun search(query: String): List<MatrixUser> {
return emptyList()
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
return null
}
}

View file

@ -23,17 +23,20 @@ import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@ -64,6 +67,10 @@ class CreateRoomFlowNode @AssistedInject constructor(
override fun onCreateNewRoom() {
backstack.push(NavTarget.NewRoom)
}
override fun onOpenRoom(roomId: RoomId) {
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
}
}
createNode<CreateRoomRootNode>(buildContext, plugins = listOf(callback))
}

View file

@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode
@ -26,7 +27,21 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCreateRoomEntryPoint @Inject constructor() : CreateRoomEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext)
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreateRoomEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : CreateRoomEntryPoint.NodeBuilder {
override fun callback(callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext, plugins)
}
}
}
}

View file

@ -17,31 +17,38 @@
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
import javax.inject.Named
class AddPeoplePresenter @Inject constructor(
private val selectUsersPresenterFactory: SelectUsersPresenter.Factory,
private val userListPresenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource,
) : Presenter<AddPeopleState> {
private val selectUsersPresenter by lazy {
selectUsersPresenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Multiple))
private val userListPresenter by lazy {
userListPresenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
matrixUserDataSource,
)
}
@Composable
override fun present(): AddPeopleState {
val selectUsersState = selectUsersPresenter.present()
val userListState = userListPresenter.present()
fun handleEvents(event: AddPeopleEvents) {
// do nothing for now
}
return AddPeopleState(
selectUsersState = selectUsersState,
userListState = userListState,
eventSink = ::handleEvents,
)
}
}

View file

@ -16,9 +16,9 @@
package io.element.android.features.createroom.impl.addpeople
import io.element.android.features.selectusers.api.SelectUsersState
import io.element.android.features.userlist.api.UserListState
data class AddPeopleState(
val selectUsersState: SelectUsersState,
val userListState: UserListState,
val eventSink: (AddPeopleEvents) -> Unit,
)

View file

@ -17,22 +17,22 @@
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.selectusers.api.SelectionMode
import io.element.android.features.selectusers.api.aListOfSelectedUsers
import io.element.android.features.selectusers.api.aSelectUsersState
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.features.userlist.api.aUserListState
open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> {
override val values: Sequence<AddPeopleState>
get() = sequenceOf(
aAddPeopleState(),
aAddPeopleState().copy(
selectUsersState = aSelectUsersState().copy(
userListState = aUserListState().copy(
selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple,
)
),
aAddPeopleState().copy(
selectUsersState = aSelectUsersState().copy(
userListState = aUserListState().copy(
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,
@ -42,6 +42,6 @@ open class AddPeopleStateProvider : PreviewParameterProvider<AddPeopleState> {
}
fun aAddPeopleState() = AddPeopleState(
selectUsersState = aSelectUsersState(),
userListState = aUserListState(),
eventSink = {}
)

View file

@ -29,8 +29,8 @@ 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.userlist.api.UserListView
import io.element.android.features.createroom.impl.R
import io.element.android.features.selectusers.api.SelectUsersView
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
@ -52,9 +52,9 @@ fun AddPeopleView(
Scaffold(
topBar = {
if (!state.selectUsersState.isSearchActive) {
if (!state.userListState.isSearchActive) {
AddPeopleViewTopBar(
hasSelectedUsers = state.selectUsersState.selectedUsers.isNotEmpty(),
hasSelectedUsers = state.userListState.selectedUsers.isNotEmpty(),
onBackPressed = onBackPressed,
onNextPressed = onNextPressed,
)
@ -66,9 +66,9 @@ fun AddPeopleView(
.fillMaxSize()
.padding(padding),
) {
SelectUsersView(
UserListView(
modifier = Modifier.fillMaxWidth(),
state = state.selectUsersState,
state = state.userListState,
)
}
}

View file

@ -0,0 +1,35 @@
/*
* 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.createroom.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.createroom.impl.AllMatrixUsersDataSource
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.di.AppScope
import javax.inject.Named
@Module
@ContributesTo(AppScope::class)
interface CreateRoomModule {
@Binds
@Named("AllUsers")
fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): MatrixUserDataSource
}

View file

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

View file

@ -16,7 +16,6 @@
package io.element.android.features.createroom.impl.root
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
@ -27,7 +26,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class)
class CreateRoomRootNode @AssistedInject constructor(
@ -38,15 +37,17 @@ class CreateRoomRootNode @AssistedInject constructor(
interface Callback : Plugin {
fun onCreateNewRoom()
fun onOpenRoom(roomId: RoomId)
}
private fun onCreateNewRoom() {
plugins<Callback>().forEach { it.onCreateNewRoom() }
}
private val callback = object : Callback {
override fun onCreateNewRoom() {
plugins<Callback>().forEach { it.onCreateNewRoom() }
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Root : NavTarget
override fun onOpenRoom(roomId: RoomId) {
plugins<Callback>().forEach { it.onOpenRoom(roomId) }
}
}
@Composable
@ -56,7 +57,8 @@ class CreateRoomRootNode @AssistedInject constructor(
state = state,
modifier = modifier,
onClosePressed = this::navigateUp,
onNewRoomClicked = this::onCreateNewRoom,
onNewRoomClicked = callback::onCreateNewRoom,
onOpenDM = callback::onOpenRoom,
)
}
}

View file

@ -17,40 +17,73 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable
import io.element.android.features.selectusers.api.SelectUsersPresenter
import io.element.android.features.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.api.SelectionMode
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
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.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import timber.log.Timber
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Named
class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: SelectUsersPresenter.Factory,
private val presenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource,
private val matrixClient: MatrixClient,
) : Presenter<CreateRoomRootState> {
private val presenter by lazy {
presenterFactory.create(SelectUsersPresenterArgs(SelectionMode.Single))
presenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
matrixUserDataSource,
)
}
@Composable
override fun present(): CreateRoomRootState {
val selectUsersState = presenter.present()
val userListState = presenter.present()
val localCoroutineScope = rememberCoroutineScope()
val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
fun startDm(matrixUser: MatrixUser) {
startDmAction.value = Async.Uninitialized
val existingDM = matrixClient.findDM(matrixUser.id)
if (existingDM == null) {
localCoroutineScope.createDM(matrixUser, startDmAction)
} else {
startDmAction.value = Async.Success(existingDM.roomId)
}
}
fun handleEvents(event: CreateRoomRootEvents) {
when (event) {
is CreateRoomRootEvents.StartDM -> handleStartDM(event.matrixUser)
is CreateRoomRootEvents.StartDM -> startDm(event.matrixUser)
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action
}
}
return CreateRoomRootState(
selectUsersState = selectUsersState,
userListState = userListState,
startDmAction = startDmAction.value,
eventSink = ::handleEvents,
)
}
private fun handleStartDM(matrixUser: MatrixUser) {
Timber.d("handleStartDM: $matrixUser") // Todo handle start DM action
private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
suspend {
matrixClient.createDM(user.id).getOrThrow()
}.execute(startDmAction)
}
}

View file

@ -16,9 +16,12 @@
package io.element.android.features.createroom.impl.root
import io.element.android.features.selectusers.api.SelectUsersState
import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
data class CreateRoomRootState(
val selectUsersState: SelectUsersState,
val userListState: UserListState,
val startDmAction: Async<RoomId>,
val eventSink: (CreateRoomRootEvents) -> Unit,
)

View file

@ -17,16 +17,42 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.selectusers.api.aSelectUsersState
import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.collections.immutable.persistentListOf
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
override val values: Sequence<CreateRoomRootState>
get() = sequenceOf(
aCreateRoomRootState(),
aCreateRoomRootState().copy(
startDmAction = Async.Loading(),
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.id.value,
searchResults = persistentListOf(it),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
}
),
aCreateRoomRootState().copy(
startDmAction = Async.Failure(Throwable()),
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.id.value,
searchResults = persistentListOf(it),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
}
),
)
}
fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {},
selectUsersState = aSelectUsersState(),
startDmAction = Async.Uninitialized,
userListState = aUserListState(),
)

View file

@ -16,6 +16,7 @@
package io.element.android.features.createroom.impl.root
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -28,9 +29,11 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@ -38,7 +41,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R
import io.element.android.features.selectusers.api.SelectUsersView
import io.element.android.features.userlist.api.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
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
@ -46,6 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR
@ -56,11 +63,18 @@ fun CreateRoomRootView(
modifier: Modifier = Modifier,
onClosePressed: () -> Unit = {},
onNewRoomClicked: () -> Unit = {},
onOpenDM: (RoomId) -> Unit = {},
) {
if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) {
onOpenDM(state.startDmAction.state)
}
}
Scaffold(
modifier = modifier.fillMaxWidth(),
topBar = {
if (!state.selectUsersState.isSearchActive) {
if (!state.userListState.isSearchActive) {
CreateRoomRootViewTopBar(onClosePressed = onClosePressed)
}
}
@ -69,13 +83,19 @@ fun CreateRoomRootView(
modifier = Modifier.padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SelectUsersView(
val context = LocalContext.current
UserListView(
modifier = Modifier.fillMaxWidth(),
state = state.selectUsersState,
onUserSelected = { state.eventSink.invoke(CreateRoomRootEvents.StartDM(it)) },
state = state.userListState,
onUserSelected = {
// Fixme disabled DM creation since it can break the account data which is not correctly synced
// uncomment to enable it again or move behind a feature flag
Toast.makeText(context, "Create DM feature is disabled.", Toast.LENGTH_SHORT).show()
// state.eventSink(CreateRoomRootEvents.StartDM(it))
},
)
if (!state.selectUsersState.isSearchActive) {
if (!state.userListState.isSearchActive) {
CreateRoomActionButtonsList(
onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = { state.eventSink(CreateRoomRootEvents.InvitePeople) },
@ -83,6 +103,25 @@ fun CreateRoomRootView(
}
}
}
when (state.startDmAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = StringR.string.common_creating_room))
}
is Async.Failure -> {
RetryDialog(
content = stringResource(id = StringR.string.screen_start_chat_error_starting_chat),
onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
onRetry = {
state.userListState.selectedUsers.firstOrNull()
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
// Cancel start DM if there is no more selected user (should not happen)
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
)
}
else -> Unit
}
}
@OptIn(ExperimentalMaterial3Api::class)

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nueva sala"</string>
<string name="screen_create_room_action_invite_people">"Invitar gente"</string>
<string name="screen_create_room_add_people_title">"Añadir personas"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Nuova stanza"</string>
<string name="screen_create_room_action_invite_people">"Invita persone"</string>
<string name="screen_create_room_add_people_title">"Aggiungi persone"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Cameră nouă"</string>
<string name="screen_create_room_action_invite_people">"Invitați persoane"</string>
<string name="screen_create_room_add_people_title">"Adaugați persoane"</string>
</resources>

View file

@ -22,8 +22,8 @@ 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.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter
import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
@ -35,10 +35,7 @@ class AddPeoplePresenterTests {
@Before
fun setup() {
val selectUsersFactory = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory {
override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args)
}
presenter = AddPeoplePresenter(selectUsersFactory)
presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeMatrixUserDataSource())
}
@Test
@ -51,3 +48,4 @@ class AddPeoplePresenterTests {
}
}
}

View file

@ -22,10 +22,18 @@ 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.selectusers.api.SelectUsersPresenterArgs
import io.element.android.features.selectusers.impl.DefaultSelectUsersPresenter
import io.element.android.features.userlist.api.aUserListState
import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.features.userlist.test.FakeUserListPresenter
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
@ -33,14 +41,17 @@ import org.junit.Test
class CreateRoomRootPresenterTests {
private lateinit var userListDataSource: FakeMatrixUserDataSource
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
@Before
fun setup() {
val selectUsersPresenter = object : DefaultSelectUsersPresenter.DefaultSelectUsersFactory {
override fun create(args: SelectUsersPresenterArgs) = DefaultSelectUsersPresenter(args)
}
presenter = CreateRoomRootPresenter(selectUsersPresenter)
fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient()
userListDataSource = FakeMatrixUserDataSource()
presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, fakeMatrixClient)
}
@Test
@ -64,13 +75,82 @@ class CreateRoomRootPresenterTests {
}
@Test
fun `present - trigger start DM action`() = runTest {
fun `present - trigger create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org"))
val createDmResult = Result.success(RoomId("!createDmResult"))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmResult(createDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
}
}
@Test
fun `present - trigger retrieve DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org"))
val fakeDmResult = FakeMatrixRoom(RoomId("!fakeDmResult"))
fakeMatrixClient.givenFindDmResult(fakeDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
}
}
@Test
fun `present - trigger retry create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org"))
val createDmResult = Result.success(RoomId("!createDmResult"))
fakeUserListPresenter.givenState(aUserListState().copy(selectedUsers = persistentListOf(matrixUser)))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmError(A_THROWABLE)
fakeMatrixClient.givenCreateDmResult(createDmResult)
// Failure
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
// Cancel
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
val stateAfterCancel = awaitItem()
assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
// Failure
stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterSecondAttempt = awaitItem()
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
// Retry with success
fakeMatrixClient.givenCreateDmError(null)
stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterRetryStartDM = awaitItem()
assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
}
}
}

View file

@ -49,7 +49,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
@ -77,6 +79,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.testtags.TestTags
@ -206,6 +209,7 @@ internal fun ChangeServerSection(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun LoginForm(
state: LoginRootState,
@ -239,7 +243,11 @@ internal fun LoginForm(
modifier = Modifier
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername),
.testTag(TestTags.loginEmailUsername)
.autofill(autofillTypes = listOf(AutofillType.Username), onFill = {
loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it))
}),
label = {
Text(text = stringResource(R.string.screen_login_username_hint))
},
@ -279,7 +287,11 @@ internal fun LoginForm(
modifier = Modifier
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword),
.testTag(TestTags.loginPassword)
.autofill(autofillTypes = listOf(AutofillType.Password), onFill = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))
}),
onValueChange = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"No hemos podido acceder a este servidor. Comprueba que has introducido correctamente la dirección del servidor. Si la dirección es correcta, ponte en contacto con el administrador del servidor para obtener más ayuda."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Este servidor no soporta sliding sync."</string>
<string name="screen_change_server_form_header">"Dirección del homeserver"</string>
<string name="screen_change_server_form_notice">"Solo puedes conectarte a un servidor que soporte sliding sync. El administrador de tu servidor tendrá que configurarlo. %1$s"</string>
<string name="screen_change_server_submit">"Continuar"</string>
<string name="screen_change_server_subtitle">"¿Cuál es la dirección de tu servidor?"</string>
<string name="screen_change_server_title">"Selecciona tu servidor"</string>
<string name="screen_login_error_deactivated_account">"Esta cuenta ha sido desactivada."</string>
<string name="screen_login_error_invalid_credentials">"Usuario y/o contraseña incorrectos"</string>
<string name="screen_login_error_invalid_user_id">"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."</string>
<string name="screen_login_form_header">"Introduce tus datos"</string>
<string name="screen_login_password_hint">"Contraseña"</string>
<string name="screen_login_server_header">"Donde viven tus conversaciones"</string>
<string name="screen_login_submit">"Continuar"</string>
<string name="screen_login_title">"¡Hola de nuevo!"</string>
<string name="screen_login_username_hint">"Usuario"</string>
</resources>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"Non siamo riusciti a raggiungere questo homserver. Verifica di aver inserito correttamente l\'URL del server domestico. Se l\'URL è corretto, contatta l\'amministratore del tuo server domestico per ulteriore assistenza."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Questo server attualmente non supporta la sincronizzazione scorrevole."</string>
<string name="screen_change_server_form_header">"URL dell\'homeserver"</string>
<string name="screen_change_server_form_notice">"Puoi connetterti solo a un server esistente che supporta la sincronizzazione scorrevole. L\'amministratore del tuo server domestico dovrà configurarlo. %1$s"</string>
<string name="screen_change_server_submit">"Continua"</string>
<string name="screen_change_server_subtitle">"Qual è l\'indirizzo del tuo server?"</string>
<string name="screen_change_server_title">"Seleziona il tuo server"</string>
<string name="screen_login_error_deactivated_account">"Questo profilo è stato disattivato."</string>
<string name="screen_login_error_invalid_credentials">"Nome utente e/o password errati"</string>
<string name="screen_login_error_invalid_user_id">"Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_unsupported_authentication">"L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."</string>
<string name="screen_login_form_header">"Inserisci i tuoi dati"</string>
<string name="screen_login_password_hint">"Password"</string>
<string name="screen_login_server_header">"Dove vivono le tue conversazioni"</string>
<string name="screen_login_submit">"Continua"</string>
<string name="screen_login_title">"Bentornato!"</string>
<string name="screen_login_username_hint">"Nome utente"</string>
</resources>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_error_invalid_homeserver">"Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Momentan acest server nu oferă suport pentru sliding sync."</string>
<string name="screen_change_server_form_header">"Adresa URL a homeserver-ului"</string>
<string name="screen_change_server_form_notice">"Vă putețo conecta numai la un server existent care oferă suport pentru sliding sync. Administratorul homeserver-ului dumneavoastră va trebui să îl configureze. %1$s"</string>
<string name="screen_change_server_submit">"Continuați"</string>
<string name="screen_change_server_subtitle">"Care este adresa serverului dumneavoastră?"</string>
<string name="screen_change_server_title">"Selectați serverul"</string>
<string name="screen_login_error_deactivated_account">"Acest cont a fost dezactivat."</string>
<string name="screen_login_error_invalid_credentials">"Utilizator și/sau parolă incorecte"</string>
<string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string>
<string name="screen_login_error_unsupported_authentication">"Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver."</string>
<string name="screen_login_form_header">"Introduceți detaliile"</string>
<string name="screen_login_password_hint">"Parolă"</string>
<string name="screen_login_server_header">"Locul unde trăiesc conversațiile tale"</string>
<string name="screen_login_submit">"Continuați"</string>
<string name="screen_login_title">"Bine ați revenit!"</string>
<string name="screen_login_username_hint">"Utilizator"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"¿Estás seguro de que quieres cerrar sesión?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Cerrar sesión"</string>
<string name="screen_signout_confirmation_dialog_title">"Cerrar sesión"</string>
<string name="screen_signout_in_progress_dialog_content">"Cerrando sesión…"</string>
<string name="screen_signout_preference_item">"Cerrar sesión"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Sei sicuro di voler uscire?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Esci"</string>
<string name="screen_signout_confirmation_dialog_title">"Esci"</string>
<string name="screen_signout_in_progress_dialog_content">"Uscita in corso…"</string>
<string name="screen_signout_preference_item">"Esci"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Sunteți sigur că vreți să vă deconectați?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Deconectați-vă"</string>
<string name="screen_signout_confirmation_dialog_title">"Deconectați-vă"</string>
<string name="screen_signout_in_progress_dialog_content">"Deconectare în curs…"</string>
<string name="screen_signout_preference_item">"Deconectați-vă"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_welcome_subtitle">"Bienvenido a la beta de %1$s. Vitaminado, para mayor rapidez y sencillez."</string>
<string name="screen_onboarding_welcome_title">"Siéntente en tu Elemento"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_welcome_subtitle">"Benvenuto nella beta di %1$s. Potenziato in velocità e semplicità."</string>
<string name="screen_onboarding_welcome_title">"Sii nel tuo elemento"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_welcome_subtitle">"Bun venit la versiunea beta a %1$s. Supraalimentat, pentru viteză și simplitate."</string>
<string name="screen_onboarding_welcome_title">"Fii în Elementul tău"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?"</string>
<string name="rageshake_detection_dialog_content">"Parece que sacudes el teléfono con frustración. ¿Quieres abrir la pantalla de informe de errores?"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?"</string>
<string name="rageshake_detection_dialog_content">"Sembra che tu stia scuotendo il telefono per la frustrazione. Vuoi aprire la schermata di segnalazione dei problemi?"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crash_detection_dialog_content">"%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?"</string>
<string name="rageshake_detection_dialog_content">"Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?"</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Adjuntar captura de pantalla"</string>
<string name="screen_bug_report_contact_me">"Podéis poneros en contacto conmigo para resolver dudas relacionadas"</string>
<string name="screen_bug_report_edit_screenshot">"Editar captura de pantalla"</string>
<string name="screen_bug_report_editor_description">"Describe el problema. ¿Qué hiciste? ¿Qué esperabas que ocurriera? ¿Qué ocurrió en realidad? Por favor, detállalo todo lo que puedas."</string>
<string name="screen_bug_report_editor_placeholder">"Describe el error…"</string>
<string name="screen_bug_report_editor_supporting">"Si es posible, escriba la descripción en inglés."</string>
<string name="screen_bug_report_include_crash_logs">"Enviar registros de fallos"</string>
<string name="screen_bug_report_include_logs">"Enviar registros para ayudar"</string>
<string name="screen_bug_report_include_screenshot">"Enviar captura de pantalla"</string>
<string name="screen_bug_report_logs_description">"Para comprobar que todo funciona correctamente, se enviarán registros de fallos con su mensaje. Serán privados. Para enviar sólo tu mensaje, desactiva esta opción."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?"</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Allega istantanea schermo"</string>
<string name="screen_bug_report_contact_me">"Potete contattarmi per qualsiasi altra domanda"</string>
<string name="screen_bug_report_edit_screenshot">"Modifica istantanea schermo"</string>
<string name="screen_bug_report_editor_description">"Descrivi il bug. Che cosa hai fatto? Cosa ti aspettavi che accadesse? Cosa è effettivamente accaduto. Si prega di inserire il maggior numero di dettagli possibile."</string>
<string name="screen_bug_report_editor_placeholder">"Descrivi il problema…"</string>
<string name="screen_bug_report_editor_supporting">"Se possibile, scrivere la descrizione in inglese."</string>
<string name="screen_bug_report_include_crash_logs">"Invia i log degli arresti anomali"</string>
<string name="screen_bug_report_include_logs">"Invia i log per aiutarci"</string>
<string name="screen_bug_report_include_screenshot">"Invia istantanea schermo"</string>
<string name="screen_bug_report_logs_description">"Per verificare che le cose funzionino come previsto, i log verranno inviati con il tuo messaggio. Questi saranno privati. Per inviare solo il tuo messaggio, disattiva questa impostazione."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s si è chiuso inaspettatamente l\'ultima volta che è stato usato. Vuoi condividere con noi un rapporto sull\'arresto anomalo?"</string>
</resources>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Atașați o captură de ecran"</string>
<string name="screen_bug_report_contact_me">"Puteți să mă contactați dacă aveți întrebări suplimentare"</string>
<string name="screen_bug_report_edit_screenshot">"Editați captura de ecran"</string>
<string name="screen_bug_report_editor_description">"Vă rugăm să descrieți eroarea. Ce ați făcut? Ce vă aşteptați să se întâmple? Ce s-a întâmplat de fapt. Vă rugam să intrați în cât mai multe detalii cu putință."</string>
<string name="screen_bug_report_editor_placeholder">"Descrieți eroarea…"</string>
<string name="screen_bug_report_editor_supporting">"Dacă posibil, vă rugăm să scrieți descrierea în engleză."</string>
<string name="screen_bug_report_include_crash_logs">"Trimiteți log-uri"</string>
<string name="screen_bug_report_include_logs">"Trimiteți log-uri pentru a ajuta"</string>
<string name="screen_bug_report_include_screenshot">"Trimiteți captură de ecran"</string>
<string name="screen_bug_report_logs_description">"Pentru a verifica că lucrurile funcționează conform așteptărilor, log-uri vor fi trimise împreună cu mesajul. Acestea vor fi private. Pentru a trimite doar mesajul, dezactivați această setare."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s s-a blocat ultima dată când a fost folosit. Dorești să ne trimiti un raport?"</string>
</resources>

View file

@ -18,9 +18,9 @@ package io.element.android.features.roomdetails.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface RoomDetailsEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext): Node
fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List<Plugin>): Node
}

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.features.userlist.api)
implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api)
implementation(libs.coil.compose)
@ -52,6 +53,8 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
ksp(libs.showkase.processor)
}

View file

@ -18,6 +18,7 @@ package io.element.android.features.roomdetails.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.createNode
@ -26,7 +27,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultRoomDetailsEntryPoint @Inject constructor() : RoomDetailsEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<RoomDetailsFlowNode>(buildContext)
override fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List<Plugin>): Node {
return parentNode.createNode<RoomDetailsFlowNode>(buildContext, plugins)
}
}

View file

@ -16,4 +16,8 @@
package io.element.android.features.roomdetails.impl
sealed interface RoomDetailsEvent
sealed interface RoomDetailsEvent {
data class LeaveRoom(val needsConfirmation: Boolean) : RoomDetailsEvent
object ClearLeaveRoomWarning : RoomDetailsEvent
object ClearError : RoomDetailsEvent
}

View file

@ -24,9 +24,11 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
@ -49,11 +51,25 @@ class RoomDetailsFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable {
@Parcelize
object RoomDetails : NavTarget
@Parcelize
object RoomMemberList : NavTarget
}
interface Callback : Plugin {
fun openRoomMemberList()
}
val callback = object : Callback {
override fun openRoomMemberList() {
backstack.push(NavTarget.RoomMemberList)
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.RoomDetails -> createNode<RoomDetailsNode>(buildContext)
NavTarget.RoomDetails -> createNode<RoomDetailsNode>(buildContext, listOf(callback))
NavTarget.RoomMemberList -> createNode<RoomMemberListNode>(buildContext)
}
}

View file

@ -23,6 +23,7 @@ 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 com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@ -40,6 +41,12 @@ class RoomDetailsNode @AssistedInject constructor(
private val room: MatrixRoom,
) : Node(buildContext, plugins = plugins) {
private val callback = plugins<RoomDetailsFlowNode.Callback>().firstOrNull()
private fun openRoomMemberList() {
callback?.openRoomMemberList()
}
private fun onShareRoom(context: Context) {
val alias = room.alias ?: room.alternativeAliases.firstOrNull()
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
@ -64,6 +71,7 @@ class RoomDetailsNode @AssistedInject constructor(
modifier = modifier,
goBack = { navigateUp() },
onShareRoom = { onShareRoom(context) },
openRoomMemberList = ::openRoomMemberList,
)
}
}

View file

@ -17,17 +17,67 @@
package io.element.android.features.roomdetails.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomDetailsPresenter @Inject constructor(
private val room: MatrixRoom,
private val roomMembershipObserver: RoomMembershipObserver,
) : Presenter<RoomDetailsState> {
@Composable
override fun present(): RoomDetailsState {
// fun handleEvents(event: RoomDetailsEvent) {}
val coroutineScope = rememberCoroutineScope()
var leaveRoomWarning by remember {
mutableStateOf<LeaveRoomWarning?>(null)
}
var error by remember {
mutableStateOf<RoomDetailsError?>(null)
}
var memberCount: Async<Int> by remember { mutableStateOf(Async.Loading()) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
memberCount = runCatching { room.memberCount() }
.fold(
onSuccess = { Async.Success(it) },
onFailure = { Async.Failure(it) }
)
}
}
fun handleEvents(event: RoomDetailsEvent) {
when (event) {
is RoomDetailsEvent.LeaveRoom -> {
if (event.needsConfirmation) {
leaveRoomWarning = LeaveRoomWarning.computeLeaveRoomWarning(room.isPublic, memberCount)
} else {
coroutineScope.launch(Dispatchers.IO) {
room.leave()
.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(room.roomId)
}.onFailure {
error = RoomDetailsError.AlertGeneric
}
leaveRoomWarning = null
}
}
}
is RoomDetailsEvent.ClearLeaveRoomWarning -> leaveRoomWarning = null
RoomDetailsEvent.ClearError -> error = null
}
}
return RoomDetailsState(
roomId = room.roomId.value,
@ -35,9 +85,11 @@ class RoomDetailsPresenter @Inject constructor(
roomAlias = room.alias,
roomAvatarUrl = room.avatarUrl,
roomTopic = room.topic,
memberCount = room.members.size,
memberCount = memberCount,
isEncrypted = room.isEncrypted,
// eventSink = ::handleEvents
displayLeaveRoomWarning = leaveRoomWarning,
error = error,
eventSink = ::handleEvents
)
}
}

View file

@ -16,13 +16,40 @@
package io.element.android.features.roomdetails.impl
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.matrix.api.room.MatrixRoom
data class RoomDetailsState(
val roomId: String,
val roomName: String,
val roomAlias: String?,
val roomAvatarUrl: String?,
val roomTopic: String?,
val memberCount: Int,
val memberCount: Async<Int>,
val isEncrypted: Boolean,
// val eventSink: (RoomDetailsEvent) -> Unit
val displayLeaveRoomWarning: LeaveRoomWarning?,
val error: RoomDetailsError?,
val eventSink: (RoomDetailsEvent) -> Unit
)
sealed class LeaveRoomWarning {
object Generic : LeaveRoomWarning()
object PrivateRoom : LeaveRoomWarning()
object LastUserInRoom : LeaveRoomWarning()
companion object {
fun computeLeaveRoomWarning(isPublic: Boolean, memberCount: Async<Int>): LeaveRoomWarning {
return when {
!isPublic -> PrivateRoom
(memberCount as? Async.Success<Int>)?.state == 1 -> LastUserInRoom
else -> Generic
}
}
}
}
sealed interface RoomDetailsError {
object AlertGeneric : RoomDetailsError
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
override val values: Sequence<RoomDetailsState>
@ -25,6 +26,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aRoomDetailsState().copy(roomTopic = null),
aRoomDetailsState().copy(isEncrypted = false),
aRoomDetailsState().copy(roomAlias = null),
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
// Add other state here
)
}
@ -39,7 +41,9 @@ fun aRoomDetailsState() = RoomDetailsState(
"|| MAIL iki/Marketing " +
"|| MAI iki/Marketing " +
"|| MAI iki/Marketing...",
memberCount = 32,
memberCount = Async.Success(32),
isEncrypted = true,
// eventSink = {}
displayLeaveRoomWarning = null,
error = null,
eventSink = {}
)

View file

@ -42,11 +42,15 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -55,6 +59,7 @@ import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -62,6 +67,7 @@ fun RoomDetailsView(
state: RoomDetailsState,
goBack: () -> Unit,
onShareRoom: () -> Unit,
openRoomMemberList: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -87,13 +93,35 @@ fun RoomDetailsView(
TopicSection(roomTopic = state.roomTopic)
}
MembersSection(memberCount = state.memberCount)
val memberCount = (state.memberCount as? Async.Success<Int>)?.state
MembersSection(
memberCount = memberCount,
isLoading = state.memberCount.isLoading(),
openRoomMemberList = openRoomMemberList
)
if (state.isEncrypted) {
SecuritySection()
}
OtherActionsSection()
OtherActionsSection(onLeaveRoom = {
state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
})
if (state.displayLeaveRoomWarning != null) {
ConfirmLeaveRoomDialog(
leaveRoomWarning = state.displayLeaveRoomWarning,
onConfirmLeave = { state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false)) },
onDismiss = { state.eventSink(RoomDetailsEvent.ClearLeaveRoomWarning) }
)
}
if (state.error != null) {
ErrorDialog(
content = stringResource(StringR.string.error_unknown),
onDismiss = { state.eventSink(RoomDetailsEvent.ClearError) }
)
}
}
}
}
@ -148,12 +176,19 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
}
@Composable
internal fun MembersSection(memberCount: Int, modifier: Modifier = Modifier) {
internal fun MembersSection(
memberCount: Int?,
isLoading: Boolean,
modifier: Modifier = Modifier,
openRoomMemberList: () -> Unit
) {
PreferenceCategory(modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_people_title),
icon = Icons.Outlined.Person,
currentValue = memberCount.toString(),
currentValue = memberCount?.toString(),
onClick = openRoomMemberList,
loadingCurrentValue = isLoading,
)
PreferenceText(
title = stringResource(R.string.screen_room_details_invite_people_title),
@ -174,16 +209,38 @@ internal fun SecuritySection(modifier: Modifier = Modifier) {
}
@Composable
internal fun OtherActionsSection(modifier: Modifier = Modifier) {
internal fun OtherActionsSection(onLeaveRoom: () -> Unit, modifier: Modifier = Modifier) {
PreferenceCategory(showDivider = false, modifier = modifier) {
PreferenceText(
title = stringResource(R.string.screen_room_details_leave_room_title),
icon = ImageVector.vectorResource(R.drawable.ic_door_open),
tintColor = LocalColors.current.textActionCritical,
onClick = onLeaveRoom,
)
}
}
@Composable
internal fun ConfirmLeaveRoomDialog(
leaveRoomWarning: LeaveRoomWarning,
onConfirmLeave: () -> Unit,
onDismiss: () -> Unit
) {
val content = stringResource(
when (leaveRoomWarning) {
LeaveRoomWarning.PrivateRoom -> StringR.string.leave_room_alert_private_subtitle
LeaveRoomWarning.LastUserInRoom -> StringR.string.leave_room_alert_empty_subtitle
LeaveRoomWarning.Generic -> StringR.string.leave_room_alert_subtitle
}
)
ConfirmationDialog(
content = content,
submitText = stringResource(StringR.string.action_leave),
onSubmitClicked = onConfirmLeave,
onDismiss = onDismiss,
)
}
@Preview
@Composable
fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) =
@ -200,5 +257,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
state = state,
goBack = {},
onShareRoom = {},
openRoomMemberList = {},
)
}

View file

@ -0,0 +1,35 @@
/*
* 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.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import io.element.android.features.roomdetails.impl.members.RoomMatrixUserDataSource
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.di.RoomScope
import javax.inject.Named
@Module
@ContributesTo(RoomScope::class)
interface RoomMemberModule {
@Binds
@Named("RoomMembers")
fun bindRoomMemberUserListDataSource(dataSource: RoomMatrixUserDataSource): MatrixUserDataSource
}

View file

@ -0,0 +1,58 @@
/*
* 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.members
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.avatar.AvatarData
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.RoomMember
import io.element.android.libraries.matrix.ui.model.MatrixUser
import javax.inject.Inject
class RoomMatrixUserDataSource @Inject constructor(
private val room: MatrixRoom
) : MatrixUserDataSource {
override suspend fun search(query: String): List<MatrixUser> {
return room.members().filter { member ->
if (query.isBlank()) {
true
} else {
member.userId.contains(query, ignoreCase = true) || member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}.map(::mapMemberToMatrixUser)
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
return null
}
private fun mapMemberToMatrixUser(member: RoomMember): MatrixUser {
return MatrixUser(
id = UserId(member.userId),
username = member.displayName,
avatarData = AvatarData(
id = member.userId,
name = member.displayName,
url = member.avatarUrl
)
)
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.di.RoomScope
import io.element.android.libraries.matrix.ui.model.MatrixUser
import timber.log.Timber
@ContributesNode(RoomScope::class)
class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomMemberListPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onUserSelected(matrixUser: MatrixUser) {
Timber.d("TODO: implement user selection. User: $matrixUser")
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomMemberListView(
state = state,
modifier = modifier,
onBackPressed = { navigateUp() },
onUserSelected = ::onUserSelected,
)
}
}

View file

@ -0,0 +1,64 @@
/*
* 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.members
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Named
class RoomMemberListPresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory,
@Named("RoomMembers") private val matrixUserDataSource: MatrixUserDataSource,
) : Presenter<RoomMemberListState> {
private val userListPresenter by lazy {
userListPresenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
matrixUserDataSource,
)
}
@Composable
override fun present(): RoomMemberListState {
val userListState = userListPresenter.present()
val allUsers = remember { mutableStateOf<Async<ImmutableList<MatrixUser>>>(Async.Loading()) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
allUsers.value = Async.Success(matrixUserDataSource.search("").toImmutableList())
}
}
return RoomMemberListState(
allUsers = allUsers.value,
userListState = userListState
)
}
}

View file

@ -0,0 +1,28 @@
/*
* 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.members
import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState(
val allUsers: Async<ImmutableList<MatrixUser>>,
val userListState: UserListState,
// val eventSink: (AddPeopleEvents) -> Unit,
)

View file

@ -0,0 +1,42 @@
/*
* 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.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
override val values: Sequence<RoomMemberListState>
get() = sequenceOf(
aRoomMemberListState(allUsers = Async.Success(persistentListOf(aMatrixUser()))),
aRoomMemberListState(allUsers = Async.Loading())
)
}
internal fun aRoomMemberListState(
searchResults: ImmutableList<MatrixUser> = persistentListOf(),
allUsers: Async<ImmutableList<MatrixUser>> = Async.Uninitialized,
) =
RoomMemberListState(
userListState = aUserListState().copy(searchResults = searchResults),
allUsers = allUsers,
)

View file

@ -0,0 +1,145 @@
/*
* 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.members
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.features.userlist.api.SearchSingleUserResultItem
import io.element.android.features.userlist.api.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.ElementTextStyles
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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.model.MatrixUser
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomMemberListView(
state: RoomMemberListState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
) {
Scaffold(
topBar = {
if (!state.userListState.isSearchActive) {
RoomMemberListTopBar(onBackPressed = onBackPressed)
}
}
) { padding ->
Column(
modifier = modifier
.fillMaxWidth()
.padding(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
UserListView(
state = state.userListState,
onUserSelected = onUserSelected,
)
if (!state.userListState.isSearchActive) {
if (state.allUsers is Async.Success) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
item {
val memberCount = state.allUsers.state.count()
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount),
style = ElementTextStyles.Regular.callout,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start,
)
}
items(state.allUsers.state) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
}
}
} else if (state.allUsers.isLoading()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomMemberListTopBar(
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
) {
CenterAlignedTopAppBar(
modifier = modifier,
title = {
Text(
text = stringResource(R.string.screen_room_details_people_title),
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = { BackButton(onClick = onBackPressed) },
)
}
@Preview
@Composable
fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: RoomMemberListState) {
RoomMemberListView(state)
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"Una persona"</item>
<item quantity="other">"%1$d personas"</item>
</plurals>
<string name="screen_dm_details_block_alert_action">"Bloquear"</string>
<string name="screen_dm_details_block_alert_description">"Los usuarios bloqueados no podrán enviarte mensajes y se ocultarán todos sus mensajes. Puedes revertir esta acción en cualquier momento."</string>
<string name="screen_dm_details_block_user">"Bloquear usuario"</string>
<string name="screen_dm_details_unblock_alert_action">"Desbloquear"</string>
<string name="screen_dm_details_unblock_alert_description">"Al desbloquear al usuario, podrás volver a ver todos sus mensajes."</string>
<string name="screen_dm_details_unblock_user">"Desbloquear usuario"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Los mensajes están protegidos con \"candados\". Sólo tú y los destinatarios tenéis las llaves únicas para abrirlos."</string>
<string name="screen_room_details_encryption_enabled_title">"Cifrado de mensajes activado"</string>
<string name="screen_room_details_invite_people_title">"Invitar a otras personas"</string>
<string name="screen_room_details_leave_room_title">"Salir de la sala"</string>
<string name="screen_room_details_people_title">"Personas"</string>
<string name="screen_room_details_security_title">"Seguridad"</string>
<string name="screen_room_details_share_room_title">"Compartir sala"</string>
<string name="screen_room_details_topic_title">"Tema"</string>
</resources>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"1 persona"</item>
<item quantity="other">"%1$d persone"</item>
</plurals>
<string name="screen_dm_details_block_alert_action">"Blocca"</string>
<string name="screen_dm_details_block_alert_description">"Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti i loro messaggi saranno nascosti. Potrai annullare questa azione in qualsiasi momento."</string>
<string name="screen_dm_details_block_user">"Blocca utente"</string>
<string name="screen_dm_details_unblock_alert_action">"Sblocca"</string>
<string name="screen_dm_details_unblock_alert_description">"Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi."</string>
<string name="screen_dm_details_unblock_user">"Sblocca utente"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"I messaggi sono protetti da lucchetti. Solo tu e i destinatari avete le chiavi univoche per sbloccarli."</string>
<string name="screen_room_details_encryption_enabled_title">"Crittografia messaggi abilitata"</string>
<string name="screen_room_details_invite_people_title">"Invita persone"</string>
<string name="screen_room_details_leave_room_title">"Esci dalla stanza"</string>
<string name="screen_room_details_people_title">"Persone"</string>
<string name="screen_room_details_security_title">"Sicurezza"</string>
<string name="screen_room_details_share_room_title">"Condividi stanza"</string>
<string name="screen_room_details_topic_title">"Oggetto"</string>
</resources>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"o persoană"</item>
<item quantity="few"></item>
<item quantity="other">"%1$d persoane"</item>
</plurals>
<string name="screen_dm_details_block_alert_action">"Blocați"</string>
<string name="screen_dm_details_block_alert_description">"Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând."</string>
<string name="screen_dm_details_block_user">"Blocați utilizatorul"</string>
<string name="screen_dm_details_unblock_alert_action">"Deblocați"</string>
<string name="screen_dm_details_unblock_alert_description">"La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta."</string>
<string name="screen_dm_details_unblock_user">"Deblocați utilizatorul"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca."</string>
<string name="screen_room_details_encryption_enabled_title">"Criptarea mesajelor este activată"</string>
<string name="screen_room_details_invite_people_title">"Invitați persoane"</string>
<string name="screen_room_details_leave_room_title">"Părăsiți camera"</string>
<string name="screen_room_details_people_title">"Persoane"</string>
<string name="screen_room_details_security_title">"Securitate"</string>
<string name="screen_room_details_share_room_title">"Partajați camera"</string>
<string name="screen_room_details_topic_title">"Subiect"</string>
</resources>

View file

@ -1,5 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"1 person"</item>
<item quantity="other">"%1$d people"</item>
</plurals>
<string name="screen_dm_details_block_alert_action">"Block"</string>
<string name="screen_dm_details_block_alert_description">"Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."</string>
<string name="screen_dm_details_block_user">"Block user"</string>
<string name="screen_dm_details_unblock_alert_action">"Unblock"</string>
<string name="screen_dm_details_unblock_alert_description">"On unblocking the user, you will be able to see all messages by them again."</string>
<string name="screen_dm_details_unblock_user">"Unblock user"</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_invite_people_title">"Invite people"</string>

View file

@ -20,20 +20,37 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomdetails.impl.LeaveRoomWarning
import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
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.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ExperimentalCoroutinesApi
class RoomDetailsPresenterTests {
private val roomMembershipObserver = RoomMembershipObserver(A_SESSION_ID)
@Test
fun `present - initial state is created from room info`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -42,20 +59,149 @@ class RoomDetailsPresenterTests {
Truth.assertThat(initialState.roomName).isEqualTo(room.name)
Truth.assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
Truth.assertThat(initialState.roomTopic).isEqualTo(room.topic)
Truth.assertThat(initialState.memberCount).isEqualTo(room.members.count())
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null))
Truth.assertThat(initialState.isEncrypted).isEqualTo(room.isEncrypted)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - room member count is calculated asynchronously`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.memberCount).isEqualTo(Async.Loading(null))
val finalState = awaitItem()
Truth.assertThat(finalState.memberCount).isEqualTo(Async.Success(0))
}
}
@Test
fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null)
val presenter = RoomDetailsPresenter(room)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.roomName).isEqualTo(room.displayName)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - can handle error while fetching member count`() = runTest {
val room = aMatrixRoom(name = null).apply {
givenFetchMemberResult(Result.failure(Throwable()))
}
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
Truth.assertThat(awaitItem().memberCount).isInstanceOf(Async.Failure::class.java)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
val room = aMatrixRoom(isPublic = false)
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.PrivateRoom)
}
}
@Test
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
val room = aMatrixRoom(members = listOf(aRoomMember()))
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.LastUserInRoom)
}
}
@Test
fun `present - Leave with confirmation shows a generic warning`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
val confirmationState = awaitItem()
Truth.assertThat(confirmationState.displayLeaveRoomWarning).isEqualTo(LeaveRoomWarning.Generic)
}
}
@Test
fun `present - Leave without confirmation leaves the room`() = runTest {
val room = aMatrixRoom()
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
cancelAndIgnoreRemainingEvents()
}
// Membership observer should receive a 'left room' change
roomMembershipObserver.updates.take(1)
.onEach { update -> Truth.assertThat(update.change).isEqualTo(MembershipChange.LEFT) }
.collect()
}
@Test
fun `present - ClearError removes any error present`() = runTest {
val room = aMatrixRoom().apply {
givenLeaveRoomError(Throwable())
}
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Allow room member count to load
skipItems(1)
initialState.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = false))
val errorState = awaitItem()
Truth.assertThat(errorState.error).isNotNull()
errorState.eventSink(RoomDetailsEvent.ClearError)
Truth.assertThat(awaitItem().error).isNull()
}
}
}
@ -68,6 +214,7 @@ fun aMatrixRoom(
avatarUrl: String? = "https://matrix.org/avatar.jpg",
members: List<RoomMember> = emptyList(),
isEncrypted: Boolean = true,
isPublic: Boolean = true,
) = FakeMatrixRoom(
roomId = roomId,
name = name,
@ -76,4 +223,23 @@ fun aMatrixRoom(
avatarUrl = avatarUrl,
members = members,
isEncrypted = isEncrypted,
isPublic = isPublic,
)
fun aRoomMember(
userId: UserId = A_USER_ID,
displayName: String? = null,
avatarUrl: String? = null,
membership: RoomMembershipState = RoomMembershipState.JOIN,
isNameAmbiguous: Boolean = false,
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L
) = RoomMember(
userId = userId.value,
displayName = displayName,
avatarUrl = avatarUrl,
membership = membership,
isNameAmbiguous = isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel,
)

View file

@ -0,0 +1,62 @@
/*
* 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.members
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.coroutines.test.runTest
import okhttp3.internal.toImmutableList
import org.junit.Test
class RoomMemberListPresenterTests {
@Test
fun `present - search is done automatically on start, but is async`() = runTest {
val searchResult = listOf(aMatrixUser())
val userListDataSource = FakeMatrixUserDataSource().apply {
givenSearchResult(searchResult)
}
val userListFactory = object : UserListPresenter.Factory {
override fun create(args: UserListPresenterArgs, dataSource: MatrixUserDataSource) = DefaultUserListPresenter(args, dataSource)
}
val presenter = RoomMemberListPresenter(userListFactory, userListDataSource)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java)
Truth.assertThat(initialState.userListState.isSearchActive).isFalse()
Truth.assertThat(initialState.userListState.searchResults).isEmpty()
Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single)
val loadedState = awaitItem()
Truth.assertThat((loadedState.allUsers as? Async.Success)?.state).isEqualTo(searchResult.toImmutableList())
}
}
}

View file

@ -41,6 +41,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
@ -61,6 +62,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.permissions.noop)
androidTestImplementation(libs.test.junitext)
}

View file

@ -20,5 +20,4 @@ sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
object DismissRequestVerificationPrompt : RoomListEvents
object ClearSuccessfulVerificationMessage : RoomListEvents
}

View file

@ -34,12 +34,14 @@ import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.handleSnackbarMessage
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -56,8 +58,11 @@ class RoomListPresenter @Inject constructor(
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter,
private val sessionVerificationService: SessionVerificationService,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<RoomListState> {
private val roomMembershipObserver: RoomMembershipObserver = client.roomMembershipObserver()
@Composable
override fun present(): RoomListState {
val matrixUser: MutableState<MatrixUser?> = remember {
@ -86,19 +91,11 @@ class RoomListPresenter @Inject constructor(
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed }
}
// Current verification flow status, if any (initial, requesting, accepted, etc.)
val currentVerificationFlowStatus by sessionVerificationService.verificationFlowState.collectAsState()
// We only care about the 'Finished' state to display the 'verification success' message
val presentVerificationSuccessfulMessage = remember {
derivedStateOf { currentVerificationFlowStatus == VerificationFlowState.Finished }
}
fun handleEvents(event: RoomListEvents) {
when (event) {
is RoomListEvents.UpdateFilter -> filter = event.newFilter
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
RoomListEvents.ClearSuccessfulVerificationMessage -> sessionVerificationService.reset()
}
}
@ -106,12 +103,14 @@ class RoomListPresenter @Inject constructor(
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
}
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
return RoomListState(
matrixUser = matrixUser.value,
roomList = filteredRoomSummaries.value,
filter = filter,
presentVerificationSuccessfulMessage = presentVerificationSuccessfulMessage.value,
displayVerificationPrompt = displayVerificationPrompt,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvents
)
}

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