diff --git a/.gitattributes b/.gitattributes
index 0542767eff..2062142284 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,2 @@
**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
+**/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 657dc7c772..9579e81997 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,7 +2,8 @@ name: APK Build
on:
workflow_dispatch:
- pull_request: { }
+ pull_request:
+ merge_group:
push:
branches: [ develop ]
@@ -13,14 +14,17 @@ env:
jobs:
debug:
- name: Build debug APKs
+ name: Build APKs
runs-on: ubuntu-latest
- if: github.ref != 'refs/heads/main'
+ # Skip for `main` and the merge queue if the branch is up to date with `develop`
+ if: github.ref != 'refs/heads/main' && github.event.merge_group.base_ref != 'refs/heads/develop'
strategy:
+ matrix:
+ variant: [debug, release, nightly, samples]
fail-fast: false
# Allow all jobs on develop. Just one per PR.
concurrency:
- group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}', github.sha) || format('build-debug-{0}', github.ref) }}
+ group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}-{1}', matrix.variant, github.sha) || format('build-{0}-{1}', matrix.variant, github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
@@ -38,12 +42,14 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
+ if: ${{ matrix.variant == 'debug' }}
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- - name: Upload debug APKs
+ - name: Upload APK APKs
+ if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v3
with:
name: elementx-debug
@@ -55,12 +61,12 @@ jobs:
continue-on-error: true
env:
token: ${{ secrets.DIAWI_TOKEN }}
- if: ${{ github.event_name == 'pull_request' && env.token != '' }}
+ if: ${{ matrix.variant == 'debug' && 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' && steps.diawi.conclusion == 'success' }}
+ if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }}
uses: NejcZdovc/comment-pr@v2
with:
message: |
@@ -72,8 +78,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Compile release sources
+ if: ${{ matrix.variant == 'release' }}
run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile nightly sources
+ if: ${{ matrix.variant == 'nightly' }}
run: ./gradlew compileNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Compile samples minimal
+ if: ${{ matrix.variant == 'samples' }}
run: ./gradlew :samples:minimal:assemble $CI_GRADLE_ARG_PROPERTIES
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
index 223a273b68..4d997ec632 100644
--- a/.github/workflows/danger.yml
+++ b/.github/workflows/danger.yml
@@ -1,17 +1,19 @@
name: Danger CI
-on: [pull_request]
+on: [pull_request, merge_group]
jobs:
build:
runs-on: ubuntu-latest
+ # Don't run in the merge queue again if the branch is up to date with `develop`
+ if: github.event.merge_group.base_ref != 'refs/heads/develop'
name: Danger main check
steps:
- uses: actions/checkout@v3
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
- uses: danger/danger-js@11.2.6
+ uses: danger/danger-js@11.2.8
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:
diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml
index 7b68c0077d..c1e478b15c 100644
--- a/.github/workflows/gradle-wrapper-validation.yml
+++ b/.github/workflows/gradle-wrapper-validation.yml
@@ -1,12 +1,15 @@
name: "Validate Gradle Wrapper"
on:
- pull_request: { }
+ pull_request:
+ merge_group:
push:
branches: [ main, develop ]
jobs:
validation:
name: "Validation"
+ # Don't run in the merge queue again if the branch is up to date with `develop`
+ if: github.event.merge_group.base_ref != 'refs/heads/develop'
runs-on: ubuntu-latest
# No concurrency required, this is a prerequisite to other actions and should run every time.
steps:
diff --git a/.github/workflows/maestro.yml b/.github/workflows/maestro.yml
index d3274d0b14..74fb1cfc83 100644
--- a/.github/workflows/maestro.yml
+++ b/.github/workflows/maestro.yml
@@ -40,12 +40,6 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- - name: Upload debug APKs
- uses: actions/upload-artifact@v3
- with:
- name: elementx-debug
- path: |
- app/build/outputs/apk/debug/*.apk
- uses: mobile-dev-inc/action-maestro-cloud@v1.4.1
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index ecc40760be..1efa0ae215 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -2,7 +2,8 @@ name: Code Quality Checks
on:
workflow_dispatch:
- pull_request: { }
+ pull_request:
+ merge_group:
push:
branches: [ main, develop ]
@@ -15,6 +16,8 @@ jobs:
checkScript:
name: Search for forbidden patterns
runs-on: ubuntu-latest
+ # Don't run in the merge queue again if the branch is up to date with `develop`
+ if: github.event.merge_group.base_ref != 'refs/heads/develop'
steps:
- uses: actions/checkout@v3
- name: Run code quality check suite
@@ -23,6 +26,8 @@ jobs:
check:
name: Project Check Suite
runs-on: ubuntu-latest
+ # Don't run in the merge queue again if the branch is up to date with `develop`
+ if: github.event.merge_group.base_ref != 'refs/heads/develop'
# Allow all jobs on main and develop. Just one per PR.
concurrency:
group: ${{ github.ref == 'refs/heads/main' && format('check-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-develop-{0}', github.sha) || format('check-{0}', github.ref) }}
@@ -65,7 +70,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
- uses: danger/danger-js@11.2.6
+ uses: danger/danger-js@11.2.8
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
new file mode 100644
index 0000000000..24e5b5ad9b
--- /dev/null
+++ b/.github/workflows/sonar.yml
@@ -0,0 +1,51 @@
+name: Code Quality Checks
+
+on:
+ workflow_dispatch:
+ pull_request:
+ merge_group:
+ push:
+ branches: [ main, develop ]
+
+# 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
+ CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
+
+jobs:
+ sonar:
+ name: Project Check Suite
+ runs-on: ubuntu-latest
+ # Don't run in the merge queue again if the branch is up to date with `develop`
+ if: github.event.merge_group.base_ref != 'refs/heads/develop'
+ # Allow all jobs on main and develop. Just one per PR.
+ concurrency:
+ group: ${{ github.ref == 'refs/heads/main' && format('sonar-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('sonar-develop-{0}', github.sha) || format('sonar-{0}', github.ref) }}
+ cancel-in-progress: true
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ # 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.7.0
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+ - name: 🔊 Publish results to Sonar
+ env:
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+ ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
+ if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
+ run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
+ - name: Prepare Danger
+ if: always()
+ run: |
+ npm install --save-dev @babel/core
+ npm install --save-dev @babel/plugin-transform-flow-strip-types
+ yarn add danger-plugin-lint-report --dev
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index e4f2300b4c..97a739f747 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,7 +2,8 @@ name: Test
on:
workflow_dispatch:
- pull_request: { }
+ pull_request:
+ merge_group:
push:
branches: [ main, develop ]
@@ -15,6 +16,8 @@ jobs:
tests:
name: Runs unit tests
runs-on: ubuntu-latest
+ # Don't run in the merge queue again if the branch is up to date with `develop`
+ if: github.event.merge_group.base_ref != 'refs/heads/develop'
# Allow all jobs on main and develop. Just one per PR.
concurrency:
diff --git a/.github/workflows/validate-lfs.yml b/.github/workflows/validate-lfs.yml
index 25fe50359c..63ded8f4e1 100644
--- a/.github/workflows/validate-lfs.yml
+++ b/.github/workflows/validate-lfs.yml
@@ -1,10 +1,12 @@
name: Validate Git LFS
-on: [pull_request]
+on: [pull_request, merge_group]
jobs:
build:
runs-on: ubuntu-latest
+ # Don't run in the merge queue again if the branch is up to date with `develop`
+ if: github.event.merge_group.base_ref != 'refs/heads/develop'
name: Validate
steps:
- uses: nschloe/action-cached-lfs-checkout@v1.2.1
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 9a55c2de1f..fdf8d994a6 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-./gradlew check +./tools/quality/check.shSome separate commands can also be run, see below. +#### detekt + +
+./gradlew detekt ++ #### ktlint
@@ -153,7 +158,7 @@ Make sure the following commands execute without any error: ### Tests -Element X is currently supported on Android Lollipop (API 21+): please test your change on an Android device (or Android emulator) running with API 21. Many issues can happen (including crashes) on older devices. +Element X is currently supported on Android Marshmallow (API 23+): please test your change on an Android device (or Android emulator) running with API 23. Many issues can happen (including crashes) on older devices. Also, if possible, please test your change on a real device. Testing on Android emulator may not be sufficient. 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. @@ -166,7 +171,18 @@ For instance, when updating the image `src` of an ImageView, please also conside ### Jetpack Compose -When adding or editing `@Composable`, make sure that you create a `@Preview` function, with suffix `Preview`. This will also create a UI test automatically. +When adding or editing `@Composable`, make sure that you create an internal function annotated with `@DayNightPreviews`, with a name suffixed by `Preview`, and having `ElementPreview` as the root composable. + +Example: +```kotlin +@DayNightPreviews +@Composable +internal fun PinIconPreview() = ElementPreview { + PinIcon() +} +``` + +This will allow to preview the composable in both light and dark mode in Android Studio. This will also automatically add UI tests. The GitHub action [Record screenshots](https://github.com/vector-im/element-x-android/actions/workflows/recordScreenshots.yml) has to be run to record the new screenshots. The PR reviewer can trigger this for you if you're not part of the core team. ### Authors diff --git a/README.md b/README.md index e31acf87b8..f24efcd828 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,18 @@ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-x-android&metric=bugs)](https://sonarcloud.io/summary/new_code?id=vector-im_element-x-android) [![codecov](https://codecov.io/github/vector-im/element-x-android/branch/develop/graph/badge.svg?token=ecwvia7amV)](https://codecov.io/github/vector-im/element-x-android) -[![Element Android Matrix room #element-android:matrix.org](https://img.shields.io/matrix/element-android:matrix.org.svg?label=%23element-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-android:matrix.org) -[![Weblate](https://translate.element.io/widgets/element-android/-/svg-badge.svg)](https://translate.element.io/engage/element-android/?utm_source=widget) +[![Element X_Android Matrix room #element-x-android:matrix.org](https://img.shields.io/matrix/element-x-android:matrix.org.svg?label=%23element-x-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-x-android:matrix.org) +[![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element) -# element-x-android +# Element X Android -ElementX Android is a [Matrix](https://matrix.org/) Android Client provided by [Element](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionality. +Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionalities. -The application is a total rewrite of [Element-Android](https://github.com/vector-im/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 6+. The UI layer is written using Jetpack compose. +The application is a total rewrite of [Element-Android](https://github.com/vector-im/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 6+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx). + +Learn more about why we are building Element X in our blog post: [https://element.io/blog/element-x-experience-the-future-of-element/](https://element.io/blog/element-x-experience-the-future-of-element/). + +## Table of contents @@ -28,24 +32,41 @@ The application is a total rewrite of [Element-Android](https://github.com/vecto Here are some early screenshots of the application: -||||| + + +||||| |-|-|-|-| +||||| ## Rust SDK -ElementX leverages the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) through an FFI layer that the final client can directly import and use. +Element X leverages the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) through an FFI layer that the final client can directly import and use. We're doing this as a way to share code between platforms and while we've seen promising results it's still in the experimental stage and bound to change. ## Status -This project is in work in progress. The app does not cover yet all functionalities we expect. +This project is in work in progress. The app does not cover yet all functionalities we expect. The list of supported features can be found in [this issue](https://github.com/vector-im/element-x-android/issues/911). ## Contributing -Please see our [contribution guide](CONTRIBUTING.md). +Want to get actively involved in the project? You're more than welcome! A good way to start is to check the issues that are labelled with the [good first issue](https://github.com/vector-im/element-x-android/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) label. Let us know by commenting the issue that you're starting working on it. + +But first make sure to read our [contribution guide](CONTRIBUTING.md) first. -Come chat with the community in the dedicated Matrix [room](https://matrix.to/#/#element-android:matrix.org). +You can also come chat with the community in the Matrix [room](https://matrix.to/#/#element-x-android:matrix.org) dedicated to the project. ## Build instructions @@ -54,9 +75,9 @@ Makes sure to select the `app` configuration when building (as we also have samp ## Support -When you are experiencing an issue on ElementX Android, please first search in [GitHub issues](https://github.com/vector-im/element-x-android/issues) -and then in [#element-android:matrix.org](https://matrix.to/#/#element-android:matrix.org). -If after your research you still have a question, ask at [#element-android:matrix.org](https://matrix.to/#/#element-android:matrix.org). Otherwise feel free to create a GitHub issue if you encounter a bug or a crash, by explaining clearly in detail what happened. You can also perform bug reporting (Rageshake) from the Element application by shaking your phone or going to the application settings. This is especially recommended when you encounter a crash. +When you are experiencing an issue on Element X Android, please first search in [GitHub issues](https://github.com/vector-im/element-x-android/issues) +and then in [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org). +If after your research you still have a question, ask at [#element-x-android:matrix.org](https://matrix.to/#/#element-x-android:matrix.org). Otherwise feel free to create a GitHub issue if you encounter a bug or a crash, by explaining clearly in detail what happened. You can also perform bug reporting from the application settings. This is especially recommended when you encounter a crash. ## Copyright & License diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt index ec3259fb7c..da8592771c 100644 --- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -24,8 +24,7 @@ import io.element.android.x.di.DaggerAppComponent import io.element.android.x.info.logApplicationInfo import io.element.android.x.initializer.CrashInitializer import io.element.android.x.initializer.EmojiInitializer -import io.element.android.x.initializer.MatrixInitializer -import io.element.android.x.initializer.TimberInitializer +import io.element.android.x.initializer.TracingInitializer class ElementXApplication : Application(), DaggerComponentOwner { @@ -39,8 +38,7 @@ class ElementXApplication : Application(), DaggerComponentOwner { appComponent = DaggerAppComponent.factory().create(applicationContext) AppInitializer.getInstance(this).apply { initializeComponent(CrashInitializer::class.java) - initializeComponent(TimberInitializer::class.java) - initializeComponent(MatrixInitializer::class.java) + initializeComponent(TracingInitializer::class.java) initializeComponent(EmojiInitializer::class.java) } logApplicationInfo() diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt index 4d75d8601e..5fb3523d6e 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -17,11 +17,15 @@ package io.element.android.x.di import com.squareup.anvil.annotations.ContributesTo +import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.tracing.TracingService @ContributesTo(AppScope::class) interface AppBindings { fun mainDaggerComponentOwner(): MainDaggerComponentsOwner fun snackbarDispatcher(): SnackbarDispatcher + fun tracingService(): TracingService + fun bugReporter(): BugReporter } diff --git a/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt b/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt index 49c2cc5782..52e3af1aab 100644 --- a/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt +++ b/app/src/main/kotlin/io/element/android/x/icon/IconPreview.kt @@ -28,7 +28,7 @@ import io.element.android.x.R @Preview @Composable -fun IconPreview( +internal fun IconPreview( modifier: Modifier = Modifier, ) { Box(modifier = modifier) { @@ -39,7 +39,7 @@ fun IconPreview( @Preview @Composable -fun RoundIconPreview( +internal fun RoundIconPreview( modifier: Modifier = Modifier, ) { Box(modifier = modifier.clip(shape = CircleShape)) { diff --git a/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt deleted file mode 100644 index 5eebc88756..0000000000 --- a/app/src/main/kotlin/io/element/android/x/initializer/MatrixInitializer.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.x.initializer - -import android.content.Context -import androidx.startup.Initializer -import io.element.android.libraries.matrix.impl.tracing.setupTracing -import io.element.android.libraries.matrix.api.tracing.TracingConfigurations -import io.element.android.x.BuildConfig - -class MatrixInitializer : Initializer{ - - override fun create(context: Context) { - if (BuildConfig.DEBUG) { - setupTracing(TracingConfigurations.debug) - } else { - setupTracing(TracingConfigurations.release) - } - } - - override fun dependencies(): List >> = listOf(TimberInitializer::class.java) -} diff --git a/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt new file mode 100644 index 0000000000..068d439994 --- /dev/null +++ b/app/src/main/kotlin/io/element/android/x/initializer/TracingInitializer.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.x.initializer + +import android.content.Context +import androidx.startup.Initializer +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.matrix.api.tracing.TracingConfiguration +import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration +import io.element.android.x.BuildConfig +import io.element.android.x.di.AppBindings +import timber.log.Timber + +class TracingInitializer : Initializer { + + override fun create(context: Context) { + val appBindings = context.bindings () + val tracingService = appBindings.tracingService() + val bugReporter = appBindings.bugReporter() + Timber.plant(tracingService.createTimberTree()) + val tracingConfiguration = if (BuildConfig.DEBUG) { + TracingConfiguration( + filterConfiguration = TracingFilterConfigurations.debug, + writesToLogcat = true, + writesToFilesConfiguration = WriteToFilesConfiguration.Disabled + ) + } else { + TracingConfiguration( + filterConfiguration = TracingFilterConfigurations.release, + writesToLogcat = false, + writesToFilesConfiguration = WriteToFilesConfiguration.Enabled( + directory = bugReporter.logDirectory().absolutePath, + filenamePrefix = "logs" + ) + ) + } + bugReporter.cleanLogDirectoryIfNeeded() + tracingService.setupTracing(tracingConfiguration) + } + + override fun dependencies(): List >> = mutableListOf() +} diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 6abc3c656b..cffd318fb1 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -65,6 +65,8 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.features.networkmonitor.test) + testImplementation(projects.tests.testutils) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) testImplementation(projects.services.appnavstate.test) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index 64c9ec7c4f..e55f059d14 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -58,7 +58,8 @@ class LoggedInEventProcessor @Inject constructor( .filter { it } .onEach { displayMessage(CommonStrings.common_verification_complete) - }.launchIn(this) + } + .launchIn(this) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 4130e5da23..7943151a5e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -44,14 +44,14 @@ import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomLoadedFlowNode import io.element.android.features.createroom.api.CreateRoomEntryPoint +import io.element.android.features.ftue.api.FtueEntryPoint +import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.invitelist.api.InviteListEntryPoint import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.verifysession.api.VerifySessionEntryPoint -import io.element.android.features.ftue.api.FtueEntryPoint -import io.element.android.features.ftue.api.state.FtueState import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -69,10 +69,12 @@ import io.element.android.libraries.matrix.ui.di.MatrixUIBindings import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.services.appnavstate.api.AppNavigationStateService import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import timber.log.Timber @ContributesNode(AppScope::class) class LoggedInFlowNode @AssistedInject constructor( @@ -100,13 +102,13 @@ class LoggedInFlowNode @AssistedInject constructor( ) { interface Callback : Plugin { - fun onOpenBugReport() = Unit + fun onOpenBugReport() } interface LifecycleCallback : NodeLifecycleCallback { - fun onFlowCreated(identifier: String, client: MatrixClient) = Unit + fun onFlowCreated(identifier: String, client: MatrixClient) - fun onFlowReleased(identifier: String, client: MatrixClient) = Unit + fun onFlowReleased(identifier: String, client: MatrixClient) } data class Inputs( @@ -123,7 +125,6 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onBuilt() { super.onBuilt() - lifecycle.subscribe( onCreate = { plugins ().forEach { it.onFlowCreated(id, inputs.matrixClient) } @@ -138,14 +139,12 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.push(NavTarget.Ftue) } }, - onResume = { - lifecycleScope.launch { - syncService.startSync() + onStop = { + //Counterpart startSync is done in observeSyncStateAndNetworkStatus method. + coroutineScope.launch { + syncService.stopSync() } }, - onPause = { - syncService.stopSync() - }, onDestroy = { plugins ().forEach { it.onFlowReleased(id, inputs.matrixClient) } appNavigationStateService.onLeavingSpace(id) @@ -153,22 +152,23 @@ class LoggedInFlowNode @AssistedInject constructor( loggedInFlowProcessor.stopObserving() } ) - observeSyncStateAndNetworkStatus() } + @OptIn(FlowPreview::class) private fun observeSyncStateAndNetworkStatus() { lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { + repeatOnLifecycle(Lifecycle.State.STARTED) { combine( - syncService.syncState, + // small debounce to avoid spamming startSync when the state is changing quickly in case of error. + syncService.syncState.debounce(100), networkMonitor.connectivity ) { syncState, networkStatus -> - syncState == SyncState.Error && networkStatus == NetworkStatus.Online + Pair(syncState, networkStatus) } - .distinctUntilChanged() - .collect { restartSync -> - if (restartSync) { + .collect { (syncState, networkStatus) -> + Timber.d("Sync state: $syncState, network status: $networkStatus") + if (syncState != SyncState.Running && networkStatus == NetworkStatus.Online) { syncService.startSync() } } @@ -305,7 +305,8 @@ class LoggedInFlowNode @AssistedInject constructor( override fun onFtueFlowFinished() { backstack.pop() } - }).build() + }) + .build() } } } @@ -350,3 +351,4 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.push(NavTarget.InviteList) } } + diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 8910cc3976..6d386a17e5 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -21,16 +21,27 @@ import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.roomlist.RoomListService 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.delay import javax.inject.Inject +private const val DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS = 1500L + class LoggedInPresenter @Inject constructor( private val matrixClient: MatrixClient, private val permissionsPresenterFactory: PermissionsPresenter.Factory, + private val networkMonitor: NetworkMonitor, private val pushService: PushService, ) : Presenter { @@ -53,18 +64,25 @@ class LoggedInPresenter @Inject constructor( pushService.registerWith(matrixClient, pushProvider, distributor) } - val syncState = matrixClient.syncService().syncState.collectAsState() + val roomListState by matrixClient.roomListService.state.collectAsState() + val networkStatus by networkMonitor.connectivity.collectAsState() val permissionsState = postNotificationPermissionsPresenter.present() - - // fun handleEvents(event: LoggedInEvents) { - // when (event) { - // } - // } - + var showSyncSpinner by remember { + mutableStateOf(false) + } + LaunchedEffect(roomListState, networkStatus) { + showSyncSpinner = when { + networkStatus == NetworkStatus.Offline -> false + roomListState == RoomListService.State.Running -> false + else -> { + delay(DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS) + true + } + } + } return LoggedInState( - syncState = syncState.value, + showSyncSpinner = showSyncSpinner, permissionsState = permissionsState, - // eventSink = ::handleEvents ) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt index 075242cddb..bb06952a50 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt @@ -16,11 +16,9 @@ package io.element.android.appnav.loggedin -import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.permissions.api.PermissionsState data class LoggedInState( - val syncState: SyncState, + val showSyncSpinner: Boolean, val permissionsState: PermissionsState, - // val eventSink: (LoggedInEvents) -> Unit ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt index e8a8a4762c..3cfb03f123 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt @@ -17,22 +17,20 @@ package io.element.android.appnav.loggedin import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState open class LoggedInStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aLoggedInState(), - aLoggedInState(syncState = SyncState.Idle), + aLoggedInState(false), + aLoggedInState(true), // Add other state here ) } fun aLoggedInState( - syncState: SyncState = SyncState.Running, + showSyncSpinner: Boolean = true, ) = LoggedInState( - syncState = syncState, + showSyncSpinner = showSyncSpinner, permissionsState = createDummyPostNotificationPermissionsState(), - // eventSink = {} ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt index 60784ea4ed..0ade93a795 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt @@ -47,7 +47,7 @@ fun LoggedInView( modifier = Modifier .padding(top = 8.dp) .align(Alignment.TopCenter), - syncState = state.syncState, + isVisible = state.showSyncSpinner, ) PermissionsView( state = state.permissionsState, @@ -58,7 +58,7 @@ fun LoggedInView( @DayNightPreviews @Composable -fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview { +internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview { LoggedInView( state = state ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt index 5108bb8716..6d045a431a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SyncStateView.kt @@ -38,19 +38,18 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SyncStateView( - syncState: SyncState, + isVisible: Boolean, modifier: Modifier = Modifier ) { val animationSpec = spring (stiffness = 500F) AnimatedVisibility( modifier = modifier, - visible = syncState.mustBeVisible(), + visible = isVisible, enter = fadeIn(animationSpec = animationSpec), exit = fadeOut(animationSpec = animationSpec), ) { @@ -60,15 +59,15 @@ fun SyncStateView( ) { Row( modifier = Modifier - .background(color = ElementTheme.colors.bgSubtleSecondary) - .padding(horizontal = 24.dp, vertical = 10.dp), + .background(color = ElementTheme.colors.bgSubtleSecondary) + .padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { CircularProgressIndicator( modifier = Modifier - .progressSemantics() - .size(12.dp), + .progressSemantics() + .size(12.dp), color = ElementTheme.colors.textPrimary, strokeWidth = 1.5.dp, ) @@ -82,20 +81,13 @@ fun SyncStateView( } } -private fun SyncState.mustBeVisible() = when (this) { - SyncState.Idle -> true /* Cold start of the app */ - SyncState.Running -> false - SyncState.Error -> false /* In this case, the network error banner can be displayed */ - SyncState.Terminated -> true /* The app is resumed and the sync is started again */ -} - @DayNightPreviews @Composable -fun SyncStateViewPreview() = ElementPreview { +internal fun SyncStateViewPreview() = ElementPreview { // Add a box to see the shadow Box(modifier = Modifier.padding(24.dp)) { SyncStateView( - syncState = SyncState.Idle + isVisible = true ) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt index ae5ae4f8db..558f64424a 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/LoadingRoomNodeView.kt @@ -16,19 +16,13 @@ package io.element.android.appnav.room -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -38,7 +32,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView -import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -47,7 +41,6 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre 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.designsystem.theme.placeholderBackground import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings @@ -102,20 +95,7 @@ private fun LoadingRoomTopBar( BackButton(onClick = onBackClicked) }, title = { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(AvatarSize.TimelineRoom.dp) - .align(Alignment.CenterVertically) - .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape) - ) - Spacer(modifier = Modifier.width(8.dp)) - PlaceholderAtom(width = 20.dp, height = 7.dp) - Spacer(modifier = Modifier.width(7.dp)) - PlaceholderAtom(width = 45.dp, height = 7.dp) - } + IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp) }, windowInsets = WindowInsets(0.dp), ) @@ -123,12 +103,12 @@ private fun LoadingRoomTopBar( @Preview @Composable -fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = +internal fun LoadingRoomNodeViewLightPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = +internal fun LoadingRoomNodeViewDarkPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index 20ec9f48b4..661d3c5433 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -96,7 +96,8 @@ class RoomFlowNode @AssistedInject constructor( } else { backstack.newRoot(NavTarget.Loading) } - }.launchIn(lifecycleScope) + } + .launchIn(lifecycleScope) } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt index 24ec9795f7..d00c4791f7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt @@ -75,8 +75,8 @@ class RoomLoadedFlowNode @AssistedInject constructor( } interface LifecycleCallback : NodeLifecycleCallback { - fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit - fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit + fun onFlowCreated(identifier: String, room: MatrixRoom) + fun onFlowReleased(identifier: String, room: MatrixRoom) } data class Inputs( @@ -115,7 +115,8 @@ class RoomLoadedFlowNode @AssistedInject constructor( room.updateMembers() .onFailure { Timber.e(it, "Fail to fetch members for room ${room.roomId}") - }.onSuccess { + } + .onSuccess { Timber.v("Success fetching members for room ${room.roomId}") } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt index a811d0283d..4abc89e7ee 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -20,13 +20,18 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.networkmonitor.api.NetworkStatus +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService 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 io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test @@ -42,14 +47,33 @@ class LoggedInPresenterTest { } } - private fun createPresenter(): LoggedInPresenter { + @Test + fun `present - show sync spinner`() = runTest { + val roomListService = FakeRoomListService() + val presenter = createPresenter(roomListService, NetworkStatus.Online) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.showSyncSpinner).isFalse() + consumeItemsUntilPredicate { it.showSyncSpinner } + roomListService.postState(RoomListService.State.Running) + consumeItemsUntilPredicate { !it.showSyncSpinner } + } + } + + private fun createPresenter( + roomListService: RoomListService = FakeRoomListService(), + networkStatus: NetworkStatus = NetworkStatus.Offline + ): LoggedInPresenter { return LoggedInPresenter( - matrixClient = FakeMatrixClient(), + matrixClient = FakeMatrixClient(roomListService = roomListService), permissionsPresenterFactory = object : PermissionsPresenter.Factory { override fun create(permission: String): PermissionsPresenter { return NoopPermissionsPresenter() } }, + networkMonitor = FakeNetworkMonitor(networkStatus), pushService = object : PushService { override fun notificationStyleChanged() { } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt index 17b6f6deb9..f56367e5f8 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/room/LoadingRoomStateFlowFactoryTest.kt @@ -18,12 +18,12 @@ package io.element.android.appnav.room import app.cash.turbine.test import com.google.common.truth.Truth -import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom -import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import kotlinx.coroutines.test.runTest import org.junit.Test @@ -47,29 +47,29 @@ class LoadingRoomStateFlowFactoryTest { @Test fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest { val room = FakeMatrixRoom(sessionId= A_SESSION_ID, roomId = A_ROOM_ID) - val roomSummaryDataSource = FakeRoomSummaryDataSource() - val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource) + val roomListService = FakeRoomListService() + val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService) val flowFactory = LoadingRoomStateFlowFactory(matrixClient) flowFactory .create(this, A_ROOM_ID) .test { Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) matrixClient.givenGetRoomResult(A_ROOM_ID, room) - roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1)) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room)) } } @Test fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() - val matrixClient = FakeMatrixClient(A_SESSION_ID, roomSummaryDataSource = roomSummaryDataSource) + val roomListService = FakeRoomListService() + val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService) val flowFactory = LoadingRoomStateFlowFactory(matrixClient) flowFactory .create(this, A_ROOM_ID) .test { Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading) - roomSummaryDataSource.postLoadingState(RoomSummaryDataSource.LoadingState.Loaded(1)) + roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) Truth.assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error) } } diff --git a/build.gradle.kts b/build.gradle.kts index 9272514899..3dfef189e2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,11 @@ +import com.google.devtools.ksp.gradle.KspTask import kotlinx.kover.api.KoverTaskExtension +import org.apache.tools.ant.taskdefs.optional.ReplaceRegExp import org.jetbrains.kotlin.cli.common.toBooleanLenient buildscript { dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0") classpath("com.google.gms:google-services:4.3.15") } } @@ -56,7 +58,7 @@ allprojects { // activate all available (even unstable) rules. allRules = true // point to your custom config defining rules to run, overwriting default behavior - config = files("$rootDir/tools/detekt/detekt.yml") + config.from(files("$rootDir/tools/detekt/detekt.yml")) } dependencies { detektPlugins("io.nlopez.compose.rules:detekt:0.1.12") @@ -343,3 +345,21 @@ subprojects { tasks.findByName("recordPaparazziDebug")?.dependsOn(removeOldScreenshotsTask) tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) } + +// Workaround for https://github.com/airbnb/Showkase/issues/335 +subprojects { + tasks.withType () { + doLast { + fileTree(buildDir).apply { include("**/*ShowkaseExtension*.kt") }.files.forEach { file -> + ReplaceRegExp().apply { + setMatch("public fun Showkase.getMetadata") + setReplace("@Suppress(\"DEPRECATION\") public fun Showkase.getMetadata") + setFlags("g") + setByLine(true) + setFile(file) + execute() + } + } + } + } +} diff --git a/changelog.d/1064.wip b/changelog.d/1064.wip new file mode 100644 index 0000000000..f3d8af5133 --- /dev/null +++ b/changelog.d/1064.wip @@ -0,0 +1 @@ +[Poll] Add feature flag in developer options diff --git a/changelog.d/769.feature b/changelog.d/769.feature new file mode 100644 index 0000000000..8df765c27c --- /dev/null +++ b/changelog.d/769.feature @@ -0,0 +1 @@ +Allow cancelling media upload diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 9198137577..8a587b5a08 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -145,7 +145,7 @@ Then you can launch the build script from the matrix-rust-components-kotlin repo - `-m` Option to select the gradle module to build. Default is sdk. - `-t` Option to to select an android target to build against. Default will build for all targets. -So for example to build the sdk against aarch64-linux-android target and copy the generated aar to ElementX project: +So for example to build the sdk against aarch64-linux-android target and copy the generated aar to Element X project: ```shell ./scripts/build.sh -p [YOUR MATRIX RUST SDK PATH] -t aarch64-linux-android -o [YOUR element-x-android PATH]/libraries/rustsdk/matrix-rust-sdk.aar @@ -313,7 +313,7 @@ suffix `Presenter`,states MUST have a suffix `State`, etc. Also we want to have ### Push -**Note** Firebase Push is not yet implemented on the project. +**Note** Firebase is implemented, but Unified Push is not yet fully implemented on the project, so this is not possible to choose this push provider in the app at the moment. Please see the dedicated [documentation](notifications.md) for more details. @@ -342,8 +342,7 @@ We have 3 tests frameworks in place, and this should be sufficient to guarantee file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewProvider to provide different states. See for instance the file [TemplateStateProvider.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateStateProvider.kt) - - Tests on presenter with [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). See in the template the - class [TemplatePresenterTests](../features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt). +- Tests on presenter with [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). See in the template the class [TemplatePresenterTests](../features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt). **Note** For now we want to avoid using class mocking (with library such as *mockk*), because this should be not necessary. We prefer to create Fake implementation of our interfaces. Mocking can be used to mock Android framework classes though, such as `Bitmap` for instance. diff --git a/docs/images-lfs/screen_1_dark.png b/docs/images-lfs/screen_1_dark.png new file mode 100644 index 0000000000..8bdcd59305 --- /dev/null +++ b/docs/images-lfs/screen_1_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4515f7589c422197a82672cdc3e64814ccca9a9a022b806facee44dd67d51ff2 +size 1116864 diff --git a/docs/images-lfs/screen_1_light.png b/docs/images-lfs/screen_1_light.png new file mode 100644 index 0000000000..8eba38af82 --- /dev/null +++ b/docs/images-lfs/screen_1_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2afb667a8b679f4395c28407b014f074e8b74745f6ecdd6ad699a6650cee2bc7 +size 771160 diff --git a/docs/images-lfs/screen_2_dark.png b/docs/images-lfs/screen_2_dark.png new file mode 100644 index 0000000000..9a0102940f --- /dev/null +++ b/docs/images-lfs/screen_2_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:910f9ab58a197a16b1295cd6b65d406ebfff1298c9c128a326edf1ec834d7fa7 +size 332936 diff --git a/docs/images-lfs/screen_2_light.png b/docs/images-lfs/screen_2_light.png new file mode 100644 index 0000000000..1dd3106e5c --- /dev/null +++ b/docs/images-lfs/screen_2_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7b80b9124c5c04c9db3824b68ab35883d41d05918abd415f7565f87d2713cfa +size 338455 diff --git a/docs/images-lfs/screen_3_dark.png b/docs/images-lfs/screen_3_dark.png new file mode 100644 index 0000000000..ccc17333e7 --- /dev/null +++ b/docs/images-lfs/screen_3_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dadff79ae955ab1da5c0a0567960fc567526ad0d8d2ea418d8adacf3a7a400cc +size 243201 diff --git a/docs/images-lfs/screen_3_light.png b/docs/images-lfs/screen_3_light.png new file mode 100644 index 0000000000..2116f1dc4c --- /dev/null +++ b/docs/images-lfs/screen_3_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2f328b3e8bf2ebe4abc4c130e28f6fc2351f5ac339be0819e5fddb657f4a560 +size 246731 diff --git a/docs/images-lfs/screen_4_dark.png b/docs/images-lfs/screen_4_dark.png new file mode 100644 index 0000000000..5bd122a9ea --- /dev/null +++ b/docs/images-lfs/screen_4_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:519fb54f0070833b2a4671e1f0fc7b2ec60930d69b592b8a760f52a73dc4fe38 +size 132247 diff --git a/docs/images-lfs/screen_4_light.png b/docs/images-lfs/screen_4_light.png new file mode 100644 index 0000000000..ee82f3be0a --- /dev/null +++ b/docs/images-lfs/screen_4_light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f683a228d7168c75d6f42c353c1a0e169cb22cb8fa6696e9e3ffeb80854b2441 +size 131610 diff --git a/docs/images/screen1.png b/docs/images/screen1.png deleted file mode 100644 index 9f9d7747ff..0000000000 Binary files a/docs/images/screen1.png and /dev/null differ diff --git a/docs/images/screen2.png b/docs/images/screen2.png deleted file mode 100644 index a5733003d6..0000000000 Binary files a/docs/images/screen2.png and /dev/null differ diff --git a/docs/images/screen3.png b/docs/images/screen3.png deleted file mode 100644 index 3edb49d086..0000000000 Binary files a/docs/images/screen3.png and /dev/null differ diff --git a/docs/images/screen4.png b/docs/images/screen4.png deleted file mode 100644 index 53da801a1b..0000000000 Binary files a/docs/images/screen4.png and /dev/null differ diff --git a/docs/nightly_build.md b/docs/nightly_build.md index 9abd59a67b..91ea10b530 100644 --- a/docs/nightly_build.md +++ b/docs/nightly_build.md @@ -10,11 +10,11 @@ ## Configuration -The nightly build will contain what's on develop, in release mode, for the main variant. It is signed using a dedicated signature, and has a dedicated appId (`io.element.android.x.nightly`), so it can be installed along with the production version of ElementX Android. The only other difference compared to ElementX Android is a different app name. We do not want to change the app name since it will also affect some strings in the app, and we do want to do that. (TODO today, the app name is changed.) +The nightly build will contain what's on develop, in release mode, for the main variant. It is signed using a dedicated signature, and has a dedicated appId (`io.element.android.x.nightly`), so it can be installed along with the production version of Element X Android. The only other difference compared to ElementX Android is a different app name. We do not want to change the app name since it will also affect some strings in the app, and we do want to do that. (TODO today, the app name is changed.) Nightly builds are built and released to Firebase every days, and automatically. -This is recommended to exclusively use this app, with your main account, instead of ElementX Android, and fallback to ElementX Android just in case of regression, to discover as soon as possible any regression, and report it to the team. To avoid double notification, you may want to disable the notification from the Element Android production version. Just open Element Android, navigate to `Settings/Notifications` and uncheck `Enable notifications for this session` (TODO Not supported yet). +This is recommended to exclusively use this app, with your main account, instead of Element X Android, and fallback to ElementX Android just in case of regression, to discover as soon as possible any regression, and report it to the team. To avoid double notification, you may want to disable the notification from the Element Android production version. Just open Element Android, navigate to `Settings/Notifications` and uncheck `Enable notifications for this session` (TODO Not supported yet). *Note:* Due to a limitation of Firebase, the nightly build is the universal build, which means that the size of the APK is a bit bigger, but this should not have any other side effect. diff --git a/docs/screenshot_testing.md b/docs/screenshot_testing.md index 37299af7fc..79ecad20dd 100644 --- a/docs/screenshot_testing.md +++ b/docs/screenshot_testing.md @@ -13,7 +13,7 @@ ## Overview - Screenshot tests are tests which record the content of a rendered screen and verify subsequent runs to check if the screen renders differently. -- ElementX uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composable. All Composable Preview will be use to make screenshot test, thanks to the usage of [Showkase](https://github.com/airbnb/Showkase). +- Element X uses [Paparazzi](https://github.com/cashapp/paparazzi) to render, record and verify Composable. All Composable Preview will be use to make screenshot test, thanks to the usage of [Showkase](https://github.com/airbnb/Showkase). - The screenshot verification occurs on every pull request as part of the `tests.yml` workflow. ## Setup @@ -30,6 +30,14 @@ If installed correctly, `git push` and `git pull` will now include LFS content. ## Recording +Recording of screenshots is done by triggering the GitHub action [Record screenshots](https://github.com/vector-im/element-x-android/actions/workflows/recordScreenshots.yml), to avoid differences of generated binary files (png images) depending on developers' environment. + +So basically, you will create a branch, do some commits with your work on it, then push your branch, trigger the GitHub action to record the screenshots (only if you think preview may have changed), and finally create a pull request. The GitHub action will record the screenshots and commit the changes to the branch. + +You can still record the screenshots locally, but please do not commit the changes. + +To record the screenshot locally, run the following command: + ```shell ./gradlew recordPaparazziDebug ``` diff --git a/fastlane/metadata/android/en-US/changelogs/40001020.txt b/fastlane/metadata/android/en-US/changelogs/40001020.txt new file mode 100644 index 0000000000..8aacdd89af --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40001020.txt @@ -0,0 +1,2 @@ +First release of Element X 🚀! +Full changelog: https://github.com/vector-im/element-x-android/releases diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt index f6d77226b9..5aa9287d86 100644 --- a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt @@ -81,12 +81,12 @@ fun buildAnnotatedStringWithColoredPart( @Preview @Composable -fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = +internal fun AnalyticsPreferencesViewLightPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = +internal fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPreferencesStateProvider::class) state: AnalyticsPreferencesState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt index a27e6e7399..54b9add785 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -54,6 +54,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton @@ -188,29 +189,28 @@ private fun AnalyticsOptInFooter( modifier = modifier, ) { Button( + text = stringResource(id = CommonStrings.action_ok), onClick = onTermsAccepted, modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(id = CommonStrings.action_ok)) - } + ) TextButton( + text = stringResource(id = CommonStrings.action_not_now), + size = ButtonSize.Medium, onClick = onTermsDeclined, modifier = Modifier.fillMaxWidth(), - ) { - Text(text = stringResource(id = CommonStrings.action_not_now)) - } + ) } } @Preview @Composable -fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight { +internal fun AnalyticsOptInViewLightPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark { +internal fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider::class) state: AnalyticsOptInState) = ElementPreviewDark { ContentToPreview(state) } diff --git a/features/analytics/impl/src/main/res/values-de/translations.xml b/features/analytics/impl/src/main/res/values-de/translations.xml index 979048344f..44890927a4 100644 --- a/features/analytics/impl/src/main/res/values-de/translations.xml +++ b/features/analytics/impl/src/main/res/values-de/translations.xml @@ -1,10 +1,10 @@ - diff --git a/features/analytics/impl/src/main/res/values-ru/translations.xml b/features/analytics/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..805cba6bf2 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,10 @@ + +"Wir erfassen und analysieren ""keine"" Account-Daten" +"Wir werden keine personenbezogenen Daten aufzeichnen oder auswerten" "Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen." -"Sie können alle unsere Nutzerbedingungen %1$s lesen." +"Du kannst alle unsere Nutzerbedingungen %1$s lesen." "hier" -"Sie können die Analyse jederzeit in den Einstellungen deaktivieren" +"Du kannst dies jederzeit deaktivieren" "Wir geben ""keine"" Informationen an Dritte weiter" -"Helfen Sie %1$s zu verbessern" +"Hilf uns, %1$s zu verbessern" + diff --git a/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml b/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..3259b10fbd --- /dev/null +++ b/features/analytics/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,4 @@ + +"Мы не будем записывать или профилировать какие-либо персональные данные" +"Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы." +"Вы можете ознакомиться со всеми нашими условиями %1$s." +"здесь" +"Вы можете отключить эту функцию в любое время" +"Мы не будем передавать ваши данные третьим лицам" +"Помогите улучшить %1$s" ++ diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt index da1f43391b..e5f701036e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.components.UserListView import io.element.android.features.createroom.impl.userlist.UserListEvents @@ -36,7 +35,6 @@ import io.element.android.features.createroom.impl.userlist.UserListState 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.aliasButtonText import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text @@ -103,16 +101,11 @@ fun AddPeopleViewTopBar( }, navigationIcon = { BackButton(onClick = onBackPressed) }, actions = { + val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip TextButton( - modifier = Modifier.padding(horizontal = 8.dp), + text = stringResource(id = textActionResId), onClick = onNextPressed, - ) { - val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip - Text( - text = stringResource(id = textActionResId), - style = ElementTheme.typography.aliasButtonText, - ) - } + ) } ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt index 1d900cea8c..302f72d514 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt @@ -93,11 +93,11 @@ fun RoomPrivacyOption( @Preview @Composable -fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() } +internal fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() } @Preview @Composable -fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() } +internal fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() } @Composable private fun ContentToPreview() { diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt index 7b726e08a2..be6a7e0a79 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchMultipleUsersResultItem.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementThemedPreview -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -63,11 +63,11 @@ internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { Con private fun ContentToPreview() { Column { SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false) - Divider() + HorizontalDivider() SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true) - Divider() + HorizontalDivider() SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false) - Divider() + HorizontalDivider() SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt index 72ebee9615..69b528f61c 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchSingleUserResultItem.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementThemedPreview -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -59,7 +59,7 @@ internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview { Conten private fun ContentToPreview() { Column { SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false)) - Divider() + HorizontalDivider() SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true)) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt index fdcd8900b4..be2ba4ddb6 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt @@ -35,7 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.user.MatrixUser @@ -117,7 +117,7 @@ fun SearchUserBar( } ) if (index < users.lastIndex) { - Divider() + HorizontalDivider() } } } else { @@ -128,7 +128,7 @@ fun SearchUserBar( onClick = { onUserSelected(searchResult.matrixUser) } ) if (index < users.lastIndex) { - Divider() + HorizontalDivider() } } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt index 9ac8f0fbde..d1f29559bd 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetValue @@ -43,6 +44,7 @@ import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -55,7 +57,6 @@ import io.element.android.libraries.designsystem.components.button.BackButton 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.aliasButtonText import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text @@ -193,15 +194,10 @@ fun ConfigureRoomToolbar( navigationIcon = { BackButton(onClick = onBackPressed) }, actions = { TextButton( - modifier = Modifier.padding(horizontal = 8.dp), + text = stringResource(CommonStrings.action_create), enabled = isNextActionEnabled, onClick = onNextPressed, - ) { - Text( - text = stringResource(CommonStrings.action_create), - style = ElementTheme.typography.aliasButtonText, - ) - } + ) } ) } @@ -247,6 +243,9 @@ fun RoomTopic( placeholder = stringResource(CommonStrings.common_topic_placeholder), onValueChange = onTopicChanged, maxLines = 3, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), ) } @@ -277,12 +276,12 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = @Preview @Composable -fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = +internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = +internal fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt index d1484b7a4f..7a3bf6ef03 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootStateProvider.kt @@ -41,7 +41,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider"您可以在任何時候關閉它" +"Inviter des amis sur Element" "Inviter des personnes" "Une erreur s\'est produite lors de la création du salon" -"Les messages dans ce salon sont chiffrés. Une fopis activé, le chiffrement ne peut pas être désactivé." +"Les messages dans ce salon sont chiffrés. Une fois activé, le chiffrement ne peut pas être désactivé." "Salon privé (sur invitation uniquement)" "Les messages ne sont pas chiffrés et n\'importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement." "Salon public (n’importe qui)" diff --git a/features/createroom/impl/src/main/res/values-ru/translations.xml b/features/createroom/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..7837f9e7de --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,15 @@ + ++ diff --git a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..dd8afaf2e7 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,7 @@ + +"Новая комната" +"Пригласите друзей в Element" +"Пригласить людей" +"Произошла ошибка при создании комнаты" +"Сообщения в этой комнате зашифрованы. Отключить шифрование впоследствии невозможно." +"Приватная комната (только по приглашению)" +"Сообщения не зашифрованы, и каждый может их прочитать. Вы можете включить шифрование позже." +"Публичная комната (любой)" +"Название комнаты" +"Тема (необязательно)" +"Произошла ошибка при попытке открытия комнаты" +"Создать комнату" ++ diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt index 7397e5ecc5..eebef5b3f2 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt @@ -97,9 +97,11 @@ fun WelcomeView( } }, footer = { - Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) { - Text(text = stringResource(CommonStrings.action_continue)) - } + Button( + text = stringResource(CommonStrings.action_continue), + modifier = Modifier.fillMaxWidth(), + onClick = onContinueClicked + ) Spacer(modifier = Modifier.height(32.dp)) } ) diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..db4fcd21fc --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,9 @@ + +"邀請朋友使用 Element" +"聊天室名稱" +"主題(非必填)" +"建立聊天室" ++ diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml index 1c22039a26..2438518334 100644 --- a/features/ftue/impl/src/main/res/values-sk/translations.xml +++ b/features/ftue/impl/src/main/res/values-sk/translations.xml @@ -1,6 +1,6 @@"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году." +"История сообщений для зашифрованных комнат в этом обновлении будет недоступна." +"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек." +"Поехали!" +"Вот что вам необходимо знать:" +"Добро пожаловать в %1$s!" +- diff --git a/features/onboarding/impl/src/main/res/values-ru/translations.xml b/features/onboarding/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..5c8c12c2b0 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,10 @@ + +"Hovory, zdieľanie polohy, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku." +"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku." "História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii." "Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení." "Poďme na to!" diff --git a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..6c5d482cb8 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,4 @@ + ++ diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt index 21a57b48a7..8c43b815ae 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt @@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom @@ -56,8 +56,9 @@ class InviteListPresenter @Inject constructor( @Composable override fun present(): InviteListState { val invites by client - .roomSummaryDataSource - .inviteRooms() + .roomListService + .invites() + .summaries .collectAsState() var seenInvites by remember { mutableStateOf"開始吧!" +>(emptySet()) } @@ -152,8 +153,7 @@ class InviteListPresenter @Inject constructor( client.getRoom(roomId)?.use { it.leave().getOrThrow() notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) - } - Unit + }.let { } }.runCatchingUpdatingState(declinedAction) } diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt index e2e5927a66..9e64e5bc9f 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListView.kt @@ -43,7 +43,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.aliasScreenTitle -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar @@ -86,7 +86,6 @@ fun InviteListView( title = stringResource(titleResource), submitText = stringResource(CommonStrings.action_decline), cancelText = stringResource(CommonStrings.action_cancel), - emphasizeSubmitButton = true, onSubmitClicked = { state.eventSink(InviteListEvents.ConfirmDeclineInvite) }, onDismiss = { state.eventSink(InviteListEvents.CancelDeclineInvite) } ) @@ -162,7 +161,7 @@ fun InviteListContent( ) if (index != state.inviteList.lastIndex) { - Divider() + HorizontalDivider() } } } diff --git a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt index c2bbd5b023..c677fe158f 100644 --- a/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt +++ b/features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -49,8 +48,8 @@ import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAto import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.aliasButtonText import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.theme.ElementTheme @@ -133,23 +132,19 @@ internal fun DefaultInviteSummaryRow( // CTAs Row(Modifier.padding(top = 12.dp)) { OutlinedButton( - content = { Text(stringResource(CommonStrings.action_decline), style = ElementTheme.typography.aliasButtonText) }, + text = stringResource(CommonStrings.action_decline), onClick = onDeclineClicked, - modifier = Modifier - .weight(1f) - .heightIn(max = 36.dp), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + modifier = Modifier.weight(1f), + size = ButtonSize.Medium, ) Spacer(modifier = Modifier.width(12.dp)) Button( - content = { Text(stringResource(CommonStrings.action_accept), style = ElementTheme.typography.aliasButtonText) }, + text = stringResource(CommonStrings.action_accept), onClick = onAcceptClicked, - modifier = Modifier - .weight(1f) - .heightIn(max = 36.dp), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + modifier = Modifier.weight(1f), + size = ButtonSize.Medium, ) } } diff --git a/features/invitelist/impl/src/main/res/values-de/translations.xml b/features/invitelist/impl/src/main/res/values-de/translations.xml index 1e2fcc2e86..2cec59d6a0 100644 --- a/features/invitelist/impl/src/main/res/values-de/translations.xml +++ b/features/invitelist/impl/src/main/res/values-de/translations.xml @@ -1,8 +1,8 @@ - diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml index 276fc30c94..5f9f0223aa 100644 --- a/features/messages/impl/src/main/res/values-fr/translations.xml +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -5,7 +5,7 @@"Möchten Sie den Beitritt zu %1$s wirklich ablehnen?" +"Möchtest du den Beitritt zu %1$s wirklich ablehnen?" "Einladung ablehnen" -"Möchten Sie den Chat mit %1$s wirklich ablehnen?" +"Möchtest du den privaten Chat mit %1$s wirklich ablehnen?" "Chat ablehnen" "Keine Einladungen" "%1$s (%2$s) hat dich eingeladen" diff --git a/features/invitelist/impl/src/main/res/values-ru/translations.xml b/features/invitelist/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..0f1c30cb09 --- /dev/null +++ b/features/invitelist/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,9 @@ + ++ diff --git a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt index 03e0f46de8..f3eef2784c 100644 --- a/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt +++ b/features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt @@ -30,8 +30,8 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState -import io.element.android.libraries.matrix.api.room.RoomSummary -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 @@ -40,7 +40,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom -import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager import io.element.android.services.analytics.api.AnalyticsService @@ -51,9 +51,9 @@ class InviteListPresenterTests { @Test fun `present - starts empty, adds invites when received`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() + val roomListService = FakeRoomListService() val presenter = createPresenter( - FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + FakeMatrixClient(roomListService = roomListService) ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -61,7 +61,7 @@ class InviteListPresenterTests { val initialState = awaitItem() Truth.assertThat(initialState.inviteList).isEmpty() - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary())) + roomListService.postInviteRooms(listOf(aRoomSummary())) val withInviteState = awaitItem() Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1) @@ -72,9 +72,9 @@ class InviteListPresenterTests { @Test fun `present - uses user ID and avatar for direct invites`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() + val roomListService = FakeRoomListService().withDirectChatInvitation() val presenter = createPresenter( - FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + FakeMatrixClient(roomListService = roomListService) ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -98,9 +98,9 @@ class InviteListPresenterTests { @Test fun `present - includes sender details for room invites`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val presenter = createPresenter( - FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + FakeMatrixClient(roomListService = roomListService) ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -122,10 +122,10 @@ class InviteListPresenterTests { @Test fun `present - shows confirm dialog for declining direct chat invites`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() + val roomListService = FakeRoomListService().withDirectChatInvitation() val presenter = InviteListPresenter( FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ), FakeSeenInvitesStore(), FakeAnalyticsService(), @@ -148,9 +148,9 @@ class InviteListPresenterTests { @Test fun `present - shows confirm dialog for declining room invites`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val presenter = createPresenter( - FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + FakeMatrixClient(roomListService = roomListService) ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -169,9 +169,9 @@ class InviteListPresenterTests { @Test fun `present - hides confirm dialog when cancelling`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val presenter = createPresenter( - FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + FakeMatrixClient(roomListService = roomListService) ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -190,10 +190,10 @@ class InviteListPresenterTests { @Test fun `present - declines invite after confirming`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val fakeNotificationDrawerManager = FakeNotificationDrawerManager() val client = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ) val room = FakeMatrixRoom() val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager) @@ -217,9 +217,9 @@ class InviteListPresenterTests { @Test fun `present - declines invite after confirming and sets state on error`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val client = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ) val room = FakeMatrixRoom() val presenter = createPresenter(client) @@ -247,9 +247,9 @@ class InviteListPresenterTests { @Test fun `present - dismisses declining error state`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val client = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ) val room = FakeMatrixRoom() val presenter = createPresenter(client) @@ -279,10 +279,10 @@ class InviteListPresenterTests { @Test fun `present - accepts invites and sets state on success`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val fakeNotificationDrawerManager = FakeNotificationDrawerManager() val client = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ) val room = FakeMatrixRoom() val presenter = createPresenter(client = client, notificationDrawerManager = fakeNotificationDrawerManager) @@ -303,9 +303,9 @@ class InviteListPresenterTests { @Test fun `present - accepts invites and sets state on error`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val client = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ) val room = FakeMatrixRoom() val presenter = createPresenter(client) @@ -325,9 +325,9 @@ class InviteListPresenterTests { @Test fun `present - dismisses accepting error state`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().withRoomInvitation() + val roomListService = FakeRoomListService().withRoomInvitation() val client = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ) val room = FakeMatrixRoom() val presenter = createPresenter(client) @@ -352,11 +352,11 @@ class InviteListPresenterTests { @Test fun `present - stores seen invites when received`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() + val roomListService = FakeRoomListService() val store = FakeSeenInvitesStore() val presenter = InviteListPresenter( FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ), store, FakeAnalyticsService(), @@ -368,19 +368,19 @@ class InviteListPresenterTests { awaitItem() // When one invite is received, that ID is saved - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary())) + roomListService.postInviteRooms(listOf(aRoomSummary())) awaitItem() Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID)) // When a second is added, both are saved - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) + roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) awaitItem() Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2)) // When they're both dismissed, an empty set is saved - roomSummaryDataSource.postInviteRooms(listOf()) + roomListService.postInviteRooms(listOf()) awaitItem() Truth.assertThat(store.getProvidedRoomIds()).isEmpty() @@ -389,12 +389,12 @@ class InviteListPresenterTests { @Test fun `present - marks invite as new if they're unseen`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() + val roomListService = FakeRoomListService() val store = FakeSeenInvitesStore() store.publishRoomIds(setOf(A_ROOM_ID)) val presenter = InviteListPresenter( FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource, + roomListService = roomListService, ), store, FakeAnalyticsService(), @@ -405,7 +405,7 @@ class InviteListPresenterTests { }.test { awaitItem() - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) + roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))) skipItems(1) val withInviteState = awaitItem() @@ -417,7 +417,7 @@ class InviteListPresenterTests { } } - private suspend fun FakeRoomSummaryDataSource.withRoomInvitation(): FakeRoomSummaryDataSource { + private suspend fun FakeRoomListService.withRoomInvitation(): FakeRoomListService { postInviteRooms( listOf( RoomSummary.Filled( @@ -446,7 +446,7 @@ class InviteListPresenterTests { return this } - private suspend fun FakeRoomSummaryDataSource.withDirectChatInvitation(): FakeRoomSummaryDataSource { + private suspend fun FakeRoomListService.withDirectChatInvitation(): FakeRoomListService { postInviteRooms( listOf( RoomSummary.Filled( diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index 39b3e8a9a1..50d400092a 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -120,7 +120,7 @@ fun StaticMapView( @DayNightPreviews @Composable -fun StaticMapViewPreview() = ElementPreview { +internal fun StaticMapViewPreview() = ElementPreview { StaticMapView( lat = 0.0, lon = 0.0, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt index d36ead5b28..84349d97c9 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt @@ -79,7 +79,7 @@ internal fun StaticMapPlaceholder( @DayNightPreviews @Composable -fun StaticMapPlaceholderPreview( +internal fun StaticMapPlaceholderPreview( @PreviewParameter(BooleanParameterProvider::class) values: Boolean ) = ElementPreview { StaticMapPlaceholder( diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt index cdaca94b04..cfb30a5523 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt @@ -224,7 +224,7 @@ fun SendLocationView( @DayNightPreviews @Composable -fun SendLocationViewPreview( +internal fun SendLocationViewPreview( @PreviewParameter(SendLocationStateProvider::class) state: SendLocationState ) = ElementPreview { SendLocationView( diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 4a4c6756aa..91b8a2f543 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -19,7 +19,7 @@ plugins { alias(libs.plugins.anvil) alias(libs.plugins.ksp) id("kotlin-parcelize") - kotlin("plugin.serialization") version "1.8.22" + kotlin("plugin.serialization") version "1.9.0" } android { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt index 378859225e..85332ec8b3 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderView.kt @@ -38,7 +38,7 @@ import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.theme.ElementTheme @@ -55,7 +55,7 @@ fun AccountProviderView( Column(modifier = modifier .fillMaxWidth() .clickable { onClick() }) { - Divider() + HorizontalDivider() Column( modifier = Modifier .fillMaxWidth() @@ -114,12 +114,12 @@ fun AccountProviderView( @Preview @Composable -fun AccountProviderViewLightPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = +internal fun AccountProviderViewLightPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = ElementPreviewLight { ContentToPreview(item) } @Preview @Composable -fun AccountProviderViewDarkPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = +internal fun AccountProviderViewDarkPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) = ElementPreviewDark { ContentToPreview(item) } @Composable diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt index f9e9624503..ffa337fba7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt @@ -71,12 +71,12 @@ fun ChangeServerView( @Preview @Composable -fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = +internal fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = +internal fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt index 5beb14b0b4..6ef5be06ab 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt @@ -35,7 +35,6 @@ internal fun SlidingSyncNotSupportedDialog( submitText = stringResource(CommonStrings.action_learn_more), onSubmitClicked = onLearnMoreClicked, onCancelClicked = onDismiss, - emphasizeSubmitButton = true, title = stringResource(CommonStrings.dialog_title_error), content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message), ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt index 48c674e0a0..b83c0eb1ac 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/customtab/CustomTabHandler.kt @@ -41,8 +41,7 @@ class CustomTabHandler @Inject constructor( if (packageName != null) { customTabsServiceConnection = object : CustomTabsServiceConnection() { override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) { - customTabsClient = client - .also { it.warmup(0L) } + customTabsClient = client.apply { warmup(0L) } prefetchUrl(url) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt index c1235b76c5..1b7486e814 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt @@ -100,12 +100,12 @@ fun OidcView( @Preview @Composable -fun OidcViewLightPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = +internal fun OidcViewLightPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun OidcViewDarkPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = +internal fun OidcViewDarkPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt index 0f444350c9..0e35f9eda5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt @@ -127,12 +127,12 @@ fun ChangeAccountProviderView( @Preview @Composable -fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = +internal fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = +internal fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt index 123d3013c7..1a021ad605 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenter.kt @@ -92,7 +92,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor( } else if (matrixHomeServerDetails.supportsPasswordLogin) { LoginFlow.PasswordLogin } else { - throw IllegalStateException("Unsupported login flow") + error("Unsupported login flow") } }.getOrThrow() }.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 2bc002b3fc..239ee3c6ac 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -36,11 +36,10 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage -import io.element.android.libraries.designsystem.components.button.ButtonWithProgress import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.testtags.TestTags @@ -87,7 +86,7 @@ fun ConfirmAccountProviderView( }, footer = { ButtonColumnMolecule { - ButtonWithProgress( + Button( text = stringResource(id = R.string.screen_account_provider_continue), showProgress = isLoading, onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, @@ -97,14 +96,13 @@ fun ConfirmAccountProviderView( .testTag(TestTags.loginContinue) ) TextButton( + text = stringResource(id = R.string.screen_account_provider_change), onClick = onChange, enabled = true, modifier = Modifier .fillMaxWidth() .testTag(TestTags.loginChangeServer) - ) { - Text(text = stringResource(id = R.string.screen_account_provider_change)) - } + ) } } ) { @@ -143,12 +141,12 @@ fun ConfirmAccountProviderView( @Preview @Composable -fun ConfirmAccountProviderViewLightPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) = +internal fun ConfirmAccountProviderViewLightPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun ConfirmAccountProviderViewDarkPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) = +internal fun ConfirmAccountProviderViewDarkPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index d62506ff75..d06ab86b06 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -60,11 +60,11 @@ import io.element.android.features.login.impl.error.loginError import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.button.ButtonWithProgress import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Button 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.OutlinedTextField @@ -141,7 +141,7 @@ fun LoginPasswordView( // Flexible spacing to keep the submit button at the bottom Spacer(modifier = Modifier.weight(1f)) // Submit - ButtonWithProgress( + Button( text = stringResource(R.string.screen_login_submit), showProgress = isLoading, onClick = ::submit, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt index 84c5bf4fac..8cfaae2a9e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -211,12 +211,12 @@ private fun HomeserverData.toAccountProvider(): AccountProvider { @Preview @Composable -fun SearchAccountProviderViewLightPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = +internal fun SearchAccountProviderViewLightPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun SearchAccountProviderViewDarkPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = +internal fun SearchAccountProviderViewDarkPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt index 5907ff1acf..94a38fa406 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListStateProvider.kt @@ -25,7 +25,7 @@ open class WaitListStateProvider : PreviewParameterProvider"Вы уверены, что хотите отклонить приглашение в %1$s?" +"Отклонить приглашение" +"Вы уверены, что хотите отказаться от приватного общения с %1$s?" +"Отклонить чат" +"Нет приглашений" +"%1$s (%2$s) пригласил вас" +{ get() = sequenceOf( aWaitListState(loginAction = Async.Uninitialized), aWaitListState(loginAction = Async.Loading()), - aWaitListState(loginAction = Async.Failure(Throwable())), + aWaitListState(loginAction = Async.Failure(Throwable("error"))), aWaitListState(loginAction = Async.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))), aWaitListState(loginAction = Async.Success(SessionId("@alice:element.io"))), // Add other state here diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt index 73fbaf30f9..025a67516d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.ButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAbsoluteAlignment @@ -52,7 +51,6 @@ import io.element.android.libraries.architecture.Async 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.aliasButtonText import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Text @@ -141,18 +139,10 @@ private fun WaitListContent( .padding(horizontal = 16.dp, vertical = 16.dp) ) { if (state.loginAction !is Async.Success) { - TextButton( - onClick = onCancelClicked, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black, - disabledContainerColor = Color.White, - disabledContentColor = Color.Black, - ), - ) { - Text( + ElementTheme(darkTheme = true) { + TextButton( text = stringResource(CommonStrings.action_cancel), - style = ElementTheme.typography.aliasButtonText, + onClick = onCancelClicked, ) } } @@ -208,22 +198,14 @@ private fun WaitListContent( } } if (state.loginAction is Async.Success) { - Button( - onClick = { state.eventSink.invoke(WaitListEvents.Continue) }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black, - disabledContainerColor = Color.White, - disabledContentColor = Color.Black, - ), - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(bottom = 8.dp) - ) { - Text( + ElementTheme(darkTheme = true) { + Button( text = stringResource(id = CommonStrings.action_continue), - style = ElementTheme.typography.aliasButtonText, + onClick = { state.eventSink.invoke(WaitListEvents.Continue) }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp), ) } } diff --git a/features/login/impl/src/main/res/drawable/ic_homeserver.xml b/features/login/impl/src/main/res/drawable/ic_homeserver.xml deleted file mode 100644 index ee061f7007..0000000000 --- a/features/login/impl/src/main/res/drawable/ic_homeserver.xml +++ /dev/null @@ -1,13 +0,0 @@ - - diff --git a/features/login/impl/src/main/res/drawable/onboarding_icon_light.png b/features/login/impl/src/main/res/drawable/onboarding_icon_light.png deleted file mode 100644 index ffd8631c47..0000000000 Binary files a/features/login/impl/src/main/res/drawable/onboarding_icon_light.png and /dev/null differ diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml index efc7c0cf3c..965cc30c4e 100644 --- a/features/login/impl/src/main/res/values-de/translations.xml +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -3,13 +3,13 @@- -- - "Kontoanbieter wechseln" "Weiter" "Adresse des Homeservers" -"Geben Sie einen Suchbegriff oder eine Domainadresse ein." +"Gib einen Suchbegriff oder eine Domainadresse ein." "Suche nach einem Unternehmen, einer Community oder einem privaten Server." -"Finde einen Accountanbieter" +"Finde einen Kontoanbieter" "Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren." "Du bist dabei dich bei %s anzumelden" "Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren." -"Du bist dabei einen Account auf %s zu erstellen" +"Du bist dabei ein Konto auf %s zu erstellen" "Matrix.org ist ein offenes Netzwerk für sichere, dezentralisierte Kommunikation." "Andere" "Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto." @@ -31,7 +31,7 @@"Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation" "Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren." "Du bist dabei dich bei %1$s anzumelden" -"Du bist dabei einen Account auf %1$s zu erstellen" +"Du bist dabei ein Konto auf %1$s zu erstellen" "Im Moment besteht eine hohe Nachfrage nach %1$s auf %2$s. Besuche die App in ein paar Tagen wieder und versuche es erneut. Vielen Dank für deine Geduld!" diff --git a/features/login/impl/src/main/res/values-ru/translations.xml b/features/login/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..33514e9f09 --- /dev/null +++ b/features/login/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,47 @@ + ++ diff --git a/features/login/impl/src/main/res/values-zh-rTW/translations.xml b/features/login/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..ae2ccae3f5 --- /dev/null +++ b/features/login/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,16 @@ + +"Переключить аккаунт" +"Продолжить" +"Адрес домашнего сервера" +"Введите поисковый запрос или адрес домена." +"Поиск компании, сообщества или частного сервера." +"Поиск сервера учетной записи" +"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем." +"Вы собираетесь войти в %s" +"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем." +"Вы собираетесь создать учетную запись на %s" +"Matrix.org — это открытая сеть для безопасной децентрализованной связи." +"Другое" +"Используйте другого поставщика учетных записей, например, собственный частный сервер или рабочую учетную запись." +"Сменить поставщика учетной записи" +"Нам не удалось связаться с этим домашним сервером. Убедитесь, что вы правильно ввели URL-адрес домашнего сервера. Если URL-адрес указан правильно, обратитесь к администратору домашнего сервера за дополнительной помощью." +"В настоящее время этот сервер не поддерживает скользящую синхронизацию." +"URL-адрес домашнего сервера" +"Вы можете подключиться только к существующему серверу, поддерживающему скользящую синхронизацию. Администратору домашнего сервера потребуется настроить его. %1$s" +"Какой адрес у вашего сервера?" +"Данная учетная запись была деактивирована." +"Неверное имя пользователя и/или пароль" +"Это не корректный идентификатор пользователя. Ожидаемый формат: \'@user:homeserver.org\'" +"Выбранный домашний сервер не поддерживает пароль или логин OIDC. Пожалуйста, свяжитесь с администратором или выберите другой домашний сервер." +"Введите сведения о себе" +"Рады видеть вас снова!" +"Войти в %1$s" +"Сменить учетную запись" +"Частный сервер для сотрудников Element." +"Matrix — это открытая сеть для безопасной децентрализованной связи." +"Здесь будут храниться ваши разговоры - точно так же, как вы используете почтового провайдера для хранения своих писем." +"Вы собираетесь войти в %1$s" +"Вы собираетесь создать учетную запись на %1$s" +"В настоящее время существует высокий спрос на %1$s на %2$s. Вернитесь в приложение через несколько дней и попробуйте снова. + +Спасибо за терпение!" +"Добро пожаловать в %1$s!" +"Почти готово!" +"Вы зарегистрированы!" +"Продолжить" +"Выберите свой сервер" +"Пароль" +"Продолжить" +"Matrix — это открытая сеть для безопасной децентрализованной связи." +"Имя пользователя" ++ diff --git a/features/logout/api/src/main/res/values-ru/translations.xml b/features/logout/api/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..3991e27251 --- /dev/null +++ b/features/logout/api/src/main/res/values-ru/translations.xml @@ -0,0 +1,8 @@ + +"繼續" +"您即將登入%s" +"您即將在 %s 建立帳號" +"其他" +"歡迎回來!" +"您即將登入 %1$s" +"您即將在 %1$s 建立帳號" +"歡迎使用 %1$s!" +"繼續" +"選擇您的伺服器" +"密碼" +"繼續" +"使用者名稱" ++ diff --git a/features/logout/api/src/main/res/values-zh-rTW/translations.xml b/features/logout/api/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..df722a9467 --- /dev/null +++ b/features/logout/api/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,8 @@ + +"Вы уверены, что вы хотите выйти?" +"Выйти" +"Выполняется выход…" +"Выйти" +"Выйти" ++ diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index b5edaec78e..4746cff1de 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { anvil(projects.anvilcodegen) api(projects.features.messages.api) implementation(projects.features.location.api) + implementation(projects.features.poll.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) @@ -52,7 +53,6 @@ dependencies { implementation(libs.coil.compose) implementation(libs.datetime) implementation(libs.accompanist.flowlayout) - implementation(libs.androidx.recyclerview) implementation(libs.jsoup) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout.compose) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index a0a3e3a286..8a374471e3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -21,10 +21,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -41,6 +39,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent @@ -48,6 +47,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent @@ -72,10 +72,12 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.room.canRedactAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber class MessagesPresenter @AssistedInject constructor( @@ -84,6 +86,7 @@ class MessagesPresenter @AssistedInject constructor( private val timelinePresenter: TimelinePresenter, private val actionListPresenter: ActionListPresenter, private val customReactionPresenter: CustomReactionPresenter, + private val reactionSummaryPresenter: ReactionSummaryPresenter, private val retrySendMenuPresenter: RetrySendMenuPresenter, private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, @@ -105,32 +108,31 @@ class MessagesPresenter @AssistedInject constructor( val timelineState = timelinePresenter.present() val actionListState = actionListPresenter.present() val customReactionState = customReactionPresenter.present() + val reactionSummaryState = reactionSummaryPresenter.present() val retryState = retrySendMenuPresenter.present() val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) - val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value) { - value = room.displayName - } - val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value) { - value = room.avatarData() + val userHasPermissionToRedact by room.canRedactAsState(updateKey = syncUpdateFlow.value) + var roomName: Async"您確定要登出嗎?" +"登出" +"正在登出…" +"登出" +"登出" +by remember { mutableStateOf(Async.Uninitialized) } + var roomAvatar: Async by remember { mutableStateOf(Async.Uninitialized) } + LaunchedEffect(syncUpdateFlow.value) { + withContext(dispatchers.io) { + roomName = Async.Success(room.displayName) + roomAvatar = Async.Success(room.avatarData()) + } } var hasDismissedInviteDialog by rememberSaveable { mutableStateOf(false) } val inviteProgress = remember { mutableStateOf >(Async.Uninitialized) } - - val showReinvitePrompt by remember( - hasDismissedInviteDialog, - composerState.hasFocus, - syncUpdateFlow, - ) { - derivedStateOf { - !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L + var showReinvitePrompt by remember { mutableStateOf(false) } + LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow) { + withContext(dispatchers.io) { + showReinvitePrompt = !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L } } - val networkConnectionStatus by networkMonitor.connectivity.collectAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() @@ -163,10 +165,12 @@ class MessagesPresenter @AssistedInject constructor( roomName = roomName, roomAvatar = roomAvatar, userHasPermissionToSendMessage = userHasPermissionToSendMessage, + userHasPermissionToRedact = userHasPermissionToRedact, composerState = composerState, timelineState = timelineState, actionListState = actionListState, customReactionState = customReactionState, + reactionSummaryState = reactionSummaryState, retrySendMenuState = retryState, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, snackbarMessage = snackbarMessage, @@ -274,6 +278,7 @@ class MessagesPresenter @AssistedInject constructor( is TimelineItemLocationContent -> AttachmentThumbnailInfo( type = AttachmentThumbnailType.Location, ) + is TimelineItemPollContent, // TODO Polls: handle reply to is TimelineItemTextBasedContent, is TimelineItemRedactedContent, is TimelineItemStateContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 8a067a3a26..d22d54e7f3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -30,13 +31,15 @@ import io.element.android.libraries.matrix.api.core.RoomId @Immutable data class MessagesState( val roomId: RoomId, - val roomName: String, - val roomAvatar: AvatarData, + val roomName: Async , + val roomAvatar: Async , val userHasPermissionToSendMessage: Boolean, + val userHasPermissionToRedact: Boolean, val composerState: MessageComposerState, val timelineState: TimelineState, val actionListState: ActionListState, val customReactionState: CustomReactionState, + val reactionSummaryState: ReactionSummaryState, val retrySendMenuState: RetrySendMenuState, val hasNetworkConnection: Boolean, val snackbarMessage: SnackbarMessage?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index d0ddcf68f4..9b3f5073a1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.messagecomposer.aMessageCompose import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.libraries.architecture.Async @@ -29,6 +30,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.textcomposer.MessageComposerMode +import kotlinx.collections.immutable.persistentSetOf open class MessagesStateProvider : PreviewParameterProvider { override val values: Sequence @@ -38,14 +40,19 @@ open class MessagesStateProvider : PreviewParameterProvider { aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)), aMessagesState().copy(userHasPermissionToSendMessage = false), aMessagesState().copy(showReinvitePrompt = true), + aMessagesState().copy( + roomName = Async.Uninitialized, + roomAvatar = Async.Uninitialized, + ), ) } fun aMessagesState() = MessagesState( roomId = RoomId("!id:domain"), - roomName = "Room name", - roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom), + roomName = Async.Success("Room name"), + roomAvatar = Async.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), userHasPermissionToSendMessage = true, + userHasPermissionToRedact = false, composerState = aMessageComposerState().copy( text = "Hello", isFullScreen = false, @@ -62,6 +69,11 @@ fun aMessagesState() = MessagesState( customReactionState = CustomReactionState( selectedEventId = null, eventSink = {}, + selectedEmoji = persistentSetOf(), + ), + reactionSummaryState = ReactionSummaryState( + target = null, + eventSink = {}, ), hasNetworkConnection = true, snackbarMessage = null, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index b68b0eea7a..145813fe54 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -53,19 +52,24 @@ import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.messagecomposer.AttachmentsState +import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.libraries.androidutils.ui.hideKeyboard +import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialogType 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.preview.ElementPreviewDark @@ -74,6 +78,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.TopAppBar import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.SnackbarHost import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState @@ -96,7 +101,11 @@ fun MessagesView( ) { LogCompositions(tag = "MessagesScreen", msg = "Root") - AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) + AttachmentStateView( + state = state.composerState.attachmentsState, + onPreviewAttachments = onPreviewAttachments, + onCancel = { state.composerState.eventSink(MessageComposerEvents.CancelSendAttachment) }, + ) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -113,7 +122,7 @@ fun MessagesView( fun onMessageLongClicked(event: TimelineItem.Event) { Timber.v("OnMessageLongClicked= ${event.id}") localView.hideKeyboard() - state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event)) + state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event, state.userHasPermissionToRedact)) } fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { @@ -125,8 +134,14 @@ fun MessagesView( state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId)) } - fun onMoreReactionsClicked(event: TimelineItem.Event): Unit = - state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId)) + fun onEmojiReactionLongClicked(emoji: String, event: TimelineItem.Event) { + if (event.eventId == null) return + state.reactionSummaryState.eventSink(ReactionSummaryEvents.ShowReactionSummary(event.eventId, event.reactionsState.reactions, emoji)) + } + + fun onMoreReactionsClicked(event: TimelineItem.Event) { + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event)) + } Scaffold( modifier = modifier, @@ -135,8 +150,8 @@ fun MessagesView( Column { ConnectivityIndicatorView(isOnline = state.hasNetworkConnection) MessagesViewTopBar( - roomTitle = state.roomName, - roomAvatar = state.roomAvatar, + roomName = state.roomName.dataOrNull(), + roomAvatar = state.roomAvatar.dataOrNull(), onBackPressed = onBackPressed, onRoomDetailsClicked = onRoomDetailsClicked, ) @@ -157,6 +172,7 @@ fun MessagesView( } }, onReactionClicked = ::onEmojiReactionClicked, + onReactionLongClicked = ::onEmojiReactionLongClicked, onMoreReactionsClicked = ::onMoreReactionsClicked, onSendLocationClicked = onSendLocationClicked, onSwipeToReply = { targetEvent -> @@ -176,7 +192,7 @@ fun MessagesView( state = state.actionListState, onActionSelected = ::onActionSelected, onCustomReactionClicked = { event -> - state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId)) + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event)) }, onEmojiReactionClicked = ::onEmojiReactionClicked, ) @@ -191,6 +207,7 @@ fun MessagesView( } ) + ReactionSummaryView(state = state.reactionSummaryState) RetrySendMessageMenu( state = state.retrySendMenuState ) @@ -201,14 +218,13 @@ fun MessagesView( } @Composable -fun ReinviteDialog(state: MessagesState) { +private fun ReinviteDialog(state: MessagesState) { if (state.showReinvitePrompt) { ConfirmationDialog( title = stringResource(id = R.string.screen_room_invite_again_alert_title), content = stringResource(id = R.string.screen_room_invite_again_alert_message), cancelText = stringResource(id = CommonStrings.action_cancel), submitText = stringResource(id = CommonStrings.action_invite), - emphasizeSubmitButton = true, onSubmitClicked = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) }, onDismiss = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) } ) @@ -218,7 +234,8 @@ fun ReinviteDialog(state: MessagesState) { @Composable private fun AttachmentStateView( state: AttachmentsState, - onPreviewAttachments: (ImmutableList ) -> Unit + onPreviewAttachments: (ImmutableList ) -> Unit, + onCancel: () -> Unit, ) { when (state) { AttachmentsState.None -> Unit @@ -231,18 +248,21 @@ private fun AttachmentStateView( is AttachmentsState.Sending.Uploading -> ProgressDialogType.Determinate(state.progress) is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate }, - text = stringResource(id = CommonStrings.common_sending) + text = stringResource(id = CommonStrings.common_sending), + isCancellable = true, + onDismissRequest = onCancel, ) } } } @Composable -fun MessagesViewContent( +private fun MessagesViewContent( state: MessagesState, onMessageClicked: (TimelineItem.Event) -> Unit, onUserDataClicked: (UserId) -> Unit, onReactionClicked: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, @@ -266,6 +286,7 @@ fun MessagesViewContent( onUserDataClicked = onUserDataClicked, onTimestampClicked = onTimestampClicked, onReactionClicked = onReactionClicked, + onReactionLongClicked = onReactionLongClicked, onMoreReactionsClicked = onMoreReactionsClicked, onSwipeToReply = onSwipeToReply, ) @@ -286,9 +307,9 @@ fun MessagesViewContent( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MessagesViewTopBar( - roomTitle: String, - roomAvatar: AvatarData, +private fun MessagesViewTopBar( + roomName: String?, + roomAvatar: AvatarData?, modifier: Modifier = Modifier, onRoomDetailsClicked: () -> Unit = {}, onBackPressed: () -> Unit = {}, @@ -299,17 +320,17 @@ fun MessagesViewTopBar( BackButton(onClick = onBackPressed) }, title = { - Row( - modifier = Modifier.clickable { onRoomDetailsClicked() }, - verticalAlignment = Alignment.CenterVertically - ) { - Avatar(roomAvatar) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = roomTitle, - style = ElementTheme.typography.fontBodyLgMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis + val titleModifier = Modifier.clickable { onRoomDetailsClicked() } + if (roomName != null && roomAvatar != null) { + RoomAvatarAndNameRow( + roomName = roomName, + roomAvatar = roomAvatar, + modifier = titleModifier + ) + } else { + IconTitlePlaceholdersRowMolecule( + iconSize = AvatarSize.TimelineRoom.dp, + modifier = titleModifier ) } }, @@ -318,7 +339,28 @@ fun MessagesViewTopBar( } @Composable -fun CantSendMessageBanner( +private fun RoomAvatarAndNameRow( + roomName: String, + roomAvatar: AvatarData, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(roomAvatar) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = roomName, + style = ElementTheme.typography.fontBodyLgMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun CantSendMessageBanner( modifier: Modifier = Modifier, ) { Row( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt index a6244a72e3..3c796036e7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListEvents.kt @@ -20,5 +20,5 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem sealed interface ActionListEvents { object Clear : ActionListEvents - data class ComputeForMessage(val event: TimelineItem.Event) : ActionListEvents + data class ComputeForMessage(val event: TimelineItem.Event, val canRedact: Boolean) : ActionListEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index 2d18018746..f71c750c22 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt @@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.canBeCopied +import io.element.android.features.messages.impl.timeline.model.event.canReact import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta import kotlinx.collections.immutable.toImmutableList @@ -48,13 +49,20 @@ class ActionListPresenter @Inject constructor( } val displayEmojiReactions by remember { - derivedStateOf { (target.value as? ActionListState.Target.Success)?.event?.isRemote == true } + derivedStateOf { + val event = (target.value as? ActionListState.Target.Success)?.event + event?.isRemote == true && event.content.canReact() + } } fun handleEvents(event: ActionListEvents) { when (event) { ActionListEvents.Clear -> target.value = ActionListState.Target.None - is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage(event.event, target) + is ActionListEvents.ComputeForMessage -> localCoroutineScope.computeForMessage( + timelineItem = event.event, + userCanRedact = event.canRedact, + target = target, + ) } } @@ -65,7 +73,11 @@ class ActionListPresenter @Inject constructor( ) } - private fun CoroutineScope.computeForMessage(timelineItem: TimelineItem.Event, target: MutableState ) = launch { + private fun CoroutineScope.computeForMessage( + timelineItem: TimelineItem.Event, + userCanRedact: Boolean, + target: MutableState + ) = launch { target.value = ActionListState.Target.Loading(timelineItem) val actions = when (timelineItem.content) { @@ -102,7 +114,7 @@ class ActionListPresenter @Inject constructor( if (!timelineItem.isMine) { add(TimelineItemAction.ReportContent) } - if (timelineItem.isMine) { + if (timelineItem.isMine || userCanRedact) { add(TimelineItemAction.Redact) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index fd2ad94345..c1561d5458 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ListItem @@ -61,6 +62,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent @@ -72,7 +74,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.text.toSp -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.hide @@ -175,7 +177,7 @@ private fun SheetContent( .padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(14.dp)) - Divider() + HorizontalDivider() } } if (state.displayEmojiReactions) { @@ -186,7 +188,7 @@ private fun SheetContent( onCustomReactionClicked = onCustomReactionClicked, modifier = Modifier.fillMaxWidth(), ) - Divider() + HorizontalDivider() } } items( @@ -235,6 +237,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif val textContent = remember(event.content) { formatter.format(event) } when (event.content) { + is TimelineItemPollContent, // TODO Polls: handle summary is TimelineItemTextBasedContent, is TimelineItemStateContent, is TimelineItemEncryptedContent, @@ -342,7 +345,7 @@ internal fun EmojiReactionsRow( ) { Row( horizontalArrangement = Arrangement.SpaceBetween, - modifier = modifier.padding(horizontal = 28.dp, vertical = 16.dp) + modifier = modifier.padding(horizontal = 24.dp, vertical = 16.dp) ) { // TODO use most recently used emojis here when available from the Rust SDK val defaultEmojis = sequenceOf( @@ -352,21 +355,25 @@ internal fun EmojiReactionsRow( val isHighlighted = highlightedEmojis.contains(emoji) EmojiButton(emoji, isHighlighted, onEmojiReactionClicked) } - - Icon( - imageVector = Icons.Outlined.AddReaction, - contentDescription = "Emojis", - tint = MaterialTheme.colorScheme.secondary, + Box( modifier = Modifier - .size(24.dp) - .align(Alignment.CenterVertically) - .clickable( - enabled = true, - onClick = onCustomReactionClicked, - indication = rememberRipple(bounded = false, radius = emojiRippleRadius), - interactionSource = remember { MutableInteractionSource() } - ) - ) + .size(48.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.AddReaction, + contentDescription = "Emojis", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .size(24.dp) + .clickable( + enabled = true, + onClick = onCustomReactionClicked, + indication = rememberRipple(bounded = false, radius = emojiRippleRadius), + interactionSource = remember { MutableInteractionSource() } + ) + ) + } } } @@ -385,12 +392,13 @@ private fun EmojiButton( Box( modifier = modifier .size(48.dp) - .background(backgroundColor, RoundedCornerShape(24.dp)), + .background(backgroundColor, CircleShape), + contentAlignment = Alignment.Center ) { Text( emoji, - fontSize = 28.dp.toSp(), + fontSize = 24.dp.toSp(), color = Color.White, modifier = Modifier .clickable( @@ -405,7 +413,7 @@ private fun EmojiButton( @DayNightPreviews @Composable -fun SheetContentPreview( +internal fun SheetContentPreview( @PreviewParameter(ActionListStateProvider::class) state: ActionListState ) = ElementPreview { SheetContent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 3ee87c0bc8..983d016854 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.mediaupload.api.MediaSender +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlin.coroutines.coroutineContext class AttachmentsPreviewPresenter @AssistedInject constructor( @Assisted private val attachment: Attachment, @@ -50,10 +54,18 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( mutableStateOf (SendActionState.Idle) } + val ongoingSendAttachmentJob = remember { mutableStateOf (null) } + fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { when (attachmentsPreviewEvents) { - AttachmentsPreviewEvents.SendAttachment -> coroutineScope.sendAttachment(attachment, sendActionState) - AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = SendActionState.Idle + AttachmentsPreviewEvents.SendAttachment -> ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(attachment, sendActionState) + AttachmentsPreviewEvents.ClearSendState -> { + ongoingSendAttachmentJob.value?.let { + it.cancel() + ongoingSendAttachmentJob.value = null + } + sendActionState.value = SendActionState.Idle + } } } @@ -72,7 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( is Attachment.Media -> { sendMedia( mediaAttachment = attachment, - sendActionState = sendActionState + sendActionState = sendActionState, ) } } @@ -81,10 +93,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( private suspend fun sendMedia( mediaAttachment: Attachment.Media, sendActionState: MutableState , - ) { + ) = runCatching { + val context = coroutineContext val progressCallback = object : ProgressCallback { override fun onProgress(current: Long, total: Long) { - sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat()) + if (context.isActive) { + sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat()) + } } } sendActionState.value = SendActionState.Sending.Processing @@ -93,13 +108,17 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( mimeType = mediaAttachment.localMedia.info.mimeType, compressIfPossible = mediaAttachment.compressIfPossible, progressCallback = progressCallback - ).fold( - onSuccess = { - sendActionState.value = SendActionState.Done - }, - onFailure = { - sendActionState.value = SendActionState.Failure(it) + ).getOrThrow() + }.fold( + onSuccess = { + sendActionState.value = SendActionState.Done + }, + onFailure = { error -> + if (error is CancellationException) { + throw error + } else { + sendActionState.value = SendActionState.Failure(error) } - ) - } + } + ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index ee41ace4b0..de7f5cd47b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -30,7 +30,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider ProgressDialogType.Determinate(sendActionState.progress) SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate }, - text = stringResource(id = CommonStrings.common_sending) + text = stringResource(id = CommonStrings.common_sending), + isCancellable = true, + onDismissRequest = onDismissClicked, ) } is SendActionState.Failure -> { @@ -155,18 +156,14 @@ private fun AttachmentsPreviewBottomActions( ButtonRowMolecule( modifier = modifier, ) { - TextButton(onClick = onCancelClicked) { - Text(stringResource(id = CommonStrings.action_cancel)) - } - TextButton(onClick = onSendClicked) { - Text(stringResource(id = CommonStrings.action_send)) - } + TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClicked) + TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClicked) } } @Preview @Composable -fun AttachmentsPreviewViewDarkPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = +internal fun AttachmentsPreviewViewDarkPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt index 6b74918d71..0ae406efff 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesEvents.kt @@ -16,7 +16,7 @@ package io.element.android.features.messages.impl.forward -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails sealed interface ForwardMessagesEvents { data class SetSelectedRoom(val room: RoomSummaryDetails) : ForwardMessagesEvents diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt index e1d7ed3e7e..273ed5906d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt @@ -35,8 +35,8 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.room.RoomSummary -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -65,7 +65,7 @@ class ForwardMessagesPresenter @AssistedInject constructor( var results: SearchBarResultState > by remember { mutableStateOf(SearchBarResultState.NotSearching()) } val forwardingActionState: MutableState >> = remember { mutableStateOf(Async.Uninitialized) } - val summaries by client.roomSummaryDataSource.allRooms().collectAsState() + val summaries by client.roomListService.allRooms().summaries.collectAsState() LaunchedEffect(query, summaries) { val filteredSummaries = summaries.filterIsInstance () diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt index 7540766097..953a7897f6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt @@ -18,7 +18,7 @@ package io.element.android.features.messages.impl.forward import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import kotlinx.collections.immutable.ImmutableList data class ForwardMessagesState( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt index 75aacea616..56d7f63eb1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.api.room.message.RoomMessage import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt index 467a963088..bbcb05f406 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt @@ -52,7 +52,7 @@ import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogD import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.aliasScreenTitle -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.RadioButton import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.SearchBar @@ -63,7 +63,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.ui.components.SelectedRoom import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings @@ -123,11 +123,10 @@ fun ForwardMessagesView( }, actions = { TextButton( + text = stringResource(CommonStrings.action_send), enabled = state.selectedRooms.isNotEmpty(), onClick = { state.eventSink(ForwardMessagesEvents.ForwardEvent) } - ) { - Text(text = stringResource(CommonStrings.action_send)) - } + ) } ) } @@ -162,7 +161,7 @@ fun ForwardMessagesView( state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary)) } ) - Divider(modifier = Modifier.fillMaxWidth()) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) } } } @@ -187,7 +186,7 @@ fun ForwardMessagesView( state.eventSink(ForwardMessagesEvents.SetSelectedRoom(roomSummary)) } ) - Divider(modifier = Modifier.fillMaxWidth()) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) } } } @@ -283,12 +282,12 @@ private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Mo @Preview @Composable -fun ForwardMessagesViewLightPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = +internal fun ForwardMessagesViewLightPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun ForwardMessagesViewDarkPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = +internal fun ForwardMessagesViewDarkPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt index 7cc73ef32e..13a9ff3bee 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt @@ -103,14 +103,17 @@ class MediaViewerPresenter @AssistedInject constructor( ) .onSuccess { mediaFile.value = it - }.mapCatching { mediaFile -> + } + .mapCatching { mediaFile -> localMediaFactory.createFromMediaFile( mediaFile = mediaFile, mediaInfo = inputs.mediaInfo ) - }.onSuccess { + } + .onSuccess { localMedia.value = Async.Success(it) - }.onFailure { + } + .onFailure { localMedia.value = Async.Failure(it) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt index 820a34d8d4..1042261be8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerStateProvider.kt @@ -32,7 +32,7 @@ open class MediaViewerStateProvider : PreviewParameterProvider get() = sequenceOf( aMediaViewerState(), aMediaViewerState(Async.Loading()), - aMediaViewerState(Async.Failure(IllegalStateException())), + aMediaViewerState(Async.Failure(IllegalStateException("error"))), aMediaViewerState( Async.Success( LocalMedia(Uri.EMPTY, anImageInfo()) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt index 5fb14a6ca0..66f15225f7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerView.kt @@ -34,9 +34,6 @@ import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -64,6 +61,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.TopAppBar +import io.element.android.libraries.designsystem.utils.SnackbarHost import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData @@ -99,15 +97,7 @@ fun MediaViewerView( eventSink = state.eventSink ) }, - snackbarHost = { - SnackbarHost(snackbarHostState) { data -> - Snackbar( - snackbarData = data, - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.primary - ) - } - }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { Column( modifier = Modifier @@ -255,7 +245,7 @@ private fun ErrorView( @Preview @Composable -fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = +internal fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 46e57e92de..d040c503b1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -36,4 +36,5 @@ sealed interface MessageComposerEvents { object VideoFromCamera : PickAttachmentSource object Location : PickAttachmentSource } + object CancelSendAttachment : MessageComposerEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index ec16d3342d..5477b10c63 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -47,9 +47,13 @@ import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import javax.inject.Inject +import kotlin.coroutines.coroutineContext import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes @SingleIn(RoomScope::class) @@ -100,6 +104,7 @@ class MessageComposerPresenter @Inject constructor( val text: MutableState = rememberSaveable { mutableStateOf("") } + val ongoingSendAttachmentJob = remember { mutableStateOf (null) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } @@ -112,7 +117,12 @@ class MessageComposerPresenter @Inject constructor( LaunchedEffect(attachmentsState.value) { when (val attachmentStateValue = attachmentsState.value) { - is AttachmentsState.Sending.Processing -> localCoroutineScope.sendAttachment(attachmentStateValue.attachments.first(), attachmentsState) + is AttachmentsState.Sending.Processing -> { + ongoingSendAttachmentJob.value = localCoroutineScope.sendAttachment( + attachmentStateValue.attachments.first(), + attachmentsState, + ) + } else -> Unit } } @@ -169,6 +179,12 @@ class MessageComposerPresenter @Inject constructor( showAttachmentSourcePicker = false // Navigation to the location picker screen is done at the view layer } + is MessageComposerEvents.CancelSendAttachment -> { + ongoingSendAttachmentJob.value?.let { + it.cancel() + ongoingSendAttachmentJob.value == null + } + } } } @@ -212,13 +228,13 @@ class MessageComposerPresenter @Inject constructor( private fun CoroutineScope.sendAttachment( attachment: Attachment, attachmentState: MutableState , - ) = launch { - when (attachment) { - is Attachment.Media -> { + ) = when (attachment) { + is Attachment.Media -> { + launch { sendMedia( uri = attachment.localMedia.uri, mimeType = attachment.localMedia.info.mimeType, - attachmentState = attachmentState + attachmentState = attachmentState, ) } } @@ -259,19 +275,27 @@ class MessageComposerPresenter @Inject constructor( uri: Uri, mimeType: String, attachmentState: MutableState , - ) { + ) = runCatching { + val context = coroutineContext val progressCallback = object : ProgressCallback { override fun onProgress(current: Long, total: Long) { - attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat()) + if (context.isActive) { + attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat()) + } } } - mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback) - .onSuccess { - attachmentState.value = AttachmentsState.None - }.onFailure { - val snackbarMessage = SnackbarMessage(sendAttachmentError(it)) - snackbarDispatcher.post(snackbarMessage) - attachmentState.value = AttachmentsState.None - } + mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback).getOrThrow() + } + .onSuccess { + attachmentState.value = AttachmentsState.None + } + .onFailure { cause -> + attachmentState.value = AttachmentsState.None + if (cause is CancellationException) { + throw cause + } else { + val snackbarMessage = SnackbarMessage(sendAttachmentError(cause)) + snackbarDispatcher.post(snackbarMessage) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt index 89e6d7a220..de5e787a3d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageStateProvider.kt @@ -26,7 +26,7 @@ open class ReportMessageStateProvider : PreviewParameterProvider val key = emojis[index % emojis.size] - add(AggregatedReaction(key = key, count = 1 + index, isHighlighted = isHighlighted)) + add(anAggregatedReaction( + key = key, + count = index + 1, + isHighlighted = isHighlighted + )) } }.toPersistentList() ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index d7c259eba2..6e16a3b92d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -81,6 +81,7 @@ fun TimelineView( onTimestampClicked: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, + onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, ) { @@ -121,6 +122,7 @@ fun TimelineView( onUserDataClick = onUserDataClicked, inReplyToClick = ::inReplyToClicked, onReactionClick = onReactionClicked, + onReactionLongClick = onReactionLongClicked, onMoreReactionsClick = onMoreReactionsClicked, onTimestampClicked = onTimestampClicked, onSwipeToReply = onSwipeToReply, @@ -138,6 +140,7 @@ fun TimelineView( } TimelineScrollHelper( + isTimelineEmpty = state.timelineItems.isEmpty(), lazyListState = lazyListState, hasNewItems = state.hasNewItems, onScrollFinishedAt = ::onScrollFinishedAt @@ -155,6 +158,7 @@ fun TimelineItemRow( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, @@ -186,6 +190,7 @@ fun TimelineItemRow( onUserDataClick = onUserDataClick, inReplyToClick = inReplyToClick, onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, onTimestampClicked = onTimestampClicked, onSwipeToReply = { onSwipeToReply(timelineItem) }, @@ -224,6 +229,7 @@ fun TimelineItemRow( onUserDataClick = onUserDataClick, onTimestampClicked = onTimestampClicked, onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, onSwipeToReply = {}, ) @@ -237,6 +243,7 @@ fun TimelineItemRow( @Composable private fun BoxScope.TimelineScrollHelper( + isTimelineEmpty: Boolean, lazyListState: LazyListState, hasNewItems: Boolean, onScrollFinishedAt: (Int) -> Unit, @@ -254,8 +261,8 @@ private fun BoxScope.TimelineScrollHelper( } } - LaunchedEffect(isScrollFinished) { - if (isScrollFinished) { + LaunchedEffect(isScrollFinished, isTimelineEmpty) { + if (isScrollFinished && !isTimelineEmpty) { // Notify the parent composable about the first visible item index when scrolling finishes onScrollFinishedAt(lazyListState.firstVisibleItemIndex) } @@ -310,7 +317,7 @@ private fun JumpToBottomButton( @DayNightPreviews @Composable -fun TimelineViewPreview( +internal fun TimelineViewPreview( @PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent ) = ElementPreview { val timelineItems = aTimelineItemList(content) @@ -321,6 +328,7 @@ fun TimelineViewPreview( onUserDataClicked = {}, onMessageLongClicked = {}, onReactionClicked = { _, _ -> }, + onReactionLongClicked = { _, _ -> }, onMoreReactionsClicked = {}, onSwipeToReply = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt index 6e121685f2..45fd5bf186 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.timeline.components import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -31,6 +32,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Tab import androidx.compose.material3.TabRow @@ -39,6 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vanniktech.emoji.Emoji @@ -48,19 +51,22 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.theme.ElementTheme +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun EmojiPicker( onEmojiSelected: (Emoji) -> Unit, + selectedEmojis: ImmutableSet , modifier: Modifier = Modifier, ) { val coroutineScope = rememberCoroutineScope() val emojiProvider = remember { GoogleEmojiProvider() } val categories = remember { emojiProvider.categories } - val pagerState = rememberPagerState() + val pagerState = rememberPagerState(pageCount = { emojiProvider.categories.size }) Column(modifier) { TabRow( selectedTabIndex = pagerState.currentPage, @@ -82,7 +88,6 @@ fun EmojiPicker( } HorizontalPager( - pageCount = categories.size, state = pagerState, modifier = Modifier.fillMaxWidth(), ) { index -> @@ -91,12 +96,19 @@ fun EmojiPicker( modifier = Modifier.fillMaxSize(), columns = GridCells.Adaptive(minSize = 40.dp), contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceEvenly, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(category.emojis, key = { it.unicode }) { item -> + val backgroundColor = if (selectedEmojis.contains(item.unicode)) { + ElementTheme.colors.bgActionPrimaryRest + } else { + Color.Transparent + } + Box( modifier = Modifier .size(40.dp) + .background(backgroundColor, CircleShape) .clickable( enabled = true, onClick = { onEmojiSelected(item) }, @@ -132,6 +144,7 @@ internal fun EmojiPickerDarkPreview() { private fun ContentToPreview() { EmojiPicker( onEmojiSelected = {}, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + selectedEmojis = persistentSetOf("😀", "😄", "😃") ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 446846db83..de7c050bd4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -152,7 +153,7 @@ private fun ContentToPreview(state: BubbleState) { ) { MessageEventBubble( state = state, - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, ) { // Render the state as a text to better understand the previews Box( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt index 69c73a68e1..8a7ad0a61c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageStateEventContainer.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -81,13 +82,13 @@ private fun ContentToPreview() { Column { MessageStateEventContainer( isHighlighted = false, - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, ) { Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp)) } MessageStateEventContainer( isHighlighted = true, - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, ) { Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp)) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index 20658c798e..930adf36cd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -17,9 +17,10 @@ package io.element.android.features.messages.impl.timeline.components import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -54,8 +55,10 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.theme.ElementTheme @Composable +@OptIn(ExperimentalFoundationApi::class) fun MessagesReactionButton( onClick: () -> Unit, + onLongClick: () -> Unit, content: MessagesReactionsButtonContent, modifier: Modifier = Modifier, ) { @@ -82,7 +85,10 @@ fun MessagesReactionButton( .padding(vertical = 2.dp, horizontal = 2.dp) // Clip click indicator inside the outer border .clip(RoundedCornerShape(corner = CornerSize(12.dp))) - .clickable(onClick = onClick) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) // Inner border, to highlight when selected .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp))) .background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp))) @@ -107,6 +113,7 @@ sealed class MessagesReactionsButtonContent { } private val reactionEmojiLineHeight = 20.sp +private val addEmojiSize = 16.dp @Composable private fun TextContent( @@ -129,7 +136,8 @@ private fun IconContent( contentDescription = stringResource(id = R.string.screen_room_timeline_add_reaction), tint = ElementTheme.materialColors.secondary, modifier = modifier - .size(reactionEmojiLineHeight.toDp()) + .size(addEmojiSize) + ) @Composable @@ -162,7 +170,18 @@ private fun ReactionContent( internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionProvider::class) reaction: AggregatedReaction) = ElementPreview { MessagesReactionButton( content = MessagesReactionsButtonContent.Reaction(reaction), - onClick = {} + onClick = {}, + onLongClick = {} + ) +} + +@DayNightPreviews +@Composable +internal fun MessagesAddReactionButtonPreview() = ElementPreview { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), + onClick = {}, + onLongClick = {} ) } @@ -170,13 +189,10 @@ internal fun MessagesReactionButtonPreview(@PreviewParameter(AggregatedReactionP @Composable internal fun MessagesReactionExtraButtonsPreview() = ElementPreview { Row { - MessagesReactionButton( - content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), - onClick = {} - ) MessagesReactionButton( content = MessagesReactionsButtonContent.Text("12 more"), - onClick = {} + onClick = {}, + onLongClick = {} ) MessagesReactionButton( content = MessagesReactionsButtonContent.Reaction( @@ -184,7 +200,8 @@ internal fun MessagesReactionExtraButtonsPreview() = ElementPreview { key = "A very long reaction with many characters that should be truncated" ) ), - onClick = {} + onClick = {}, + onLongClick = {} ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index f0a2c3473b..bc33aab2b7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Error import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -63,7 +64,7 @@ fun TimelineEventTimestampView( onClick = onClick, onLongClick = onLongClick, indication = rememberRipple(bounded = false), - interactionSource = MutableInteractionSource() + interactionSource = remember { MutableInteractionSource() } ) } else { Modifier diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index a74b37efc5..90d3e6cd8c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -112,6 +112,7 @@ fun TimelineItemEventRow( inReplyToClick: (EventId) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, + onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, onSwipeToReply: () -> Unit, modifier: Modifier = Modifier @@ -169,6 +170,7 @@ fun TimelineItemEventRow( inReplyToClicked = ::inReplyToClicked, onUserDataClicked = ::onUserDataClicked, onReactionClicked = { emoji -> onReactionClick(emoji, event) }, + onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) }, ) } @@ -184,6 +186,7 @@ fun TimelineItemEventRow( inReplyToClicked = ::inReplyToClicked, onUserDataClicked = ::onUserDataClicked, onReactionClicked = { emoji -> onReactionClick(emoji, event) }, + onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) }, ) } @@ -224,6 +227,7 @@ private fun TimelineItemEventRowContent( inReplyToClicked: () -> Unit, onUserDataClicked: () -> Unit, onReactionClicked: (emoji: String) -> Unit, + onReactionLongClicked: (emoji: String) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, ) { @@ -292,6 +296,7 @@ private fun TimelineItemEventRowContent( reactionsState = event.reactionsState, isOutgoing = event.isMine, onReactionClicked = onReactionClicked, + onReactionLongClicked = onReactionLongClicked, onMoreReactionsClicked = { onMoreReactionsClicked(event) }, modifier = Modifier .constrainAs(reactions) { @@ -479,7 +484,7 @@ private fun ReplyToContent( val paddings = if (attachmentThumbnailInfo != null) { PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) } else { - PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 4.dp) + PaddingValues(horizontal = 12.dp, vertical = 4.dp) } Row( modifier @@ -517,42 +522,46 @@ private fun ReplyToContent( } } -private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) = - when (val type = inReplyTo.content.type) { +private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): AttachmentThumbnailInfo? { + val messageContent = inReplyTo.content as? MessageContent ?: return null + return when (val type = messageContent.type) { is ImageMessageType -> AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource, - textContent = inReplyTo.content.body, + textContent = messageContent.body, type = AttachmentThumbnailType.Image, blurHash = type.info?.blurhash, ) is VideoMessageType -> AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource, - textContent = inReplyTo.content.body, + textContent = messageContent.body, type = AttachmentThumbnailType.Video, blurHash = type.info?.blurhash, ) is FileMessageType -> AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource, - textContent = inReplyTo.content.body, + textContent = messageContent.body, type = AttachmentThumbnailType.File, ) is LocationMessageType -> AttachmentThumbnailInfo( - textContent = inReplyTo.content.body, + textContent = messageContent.body, type = AttachmentThumbnailType.Location, ) is AudioMessageType -> AttachmentThumbnailInfo( - textContent = inReplyTo.content.body, + textContent = messageContent.body, type = AttachmentThumbnailType.Audio, ) else -> null } +} @Composable -private fun textForInReplyTo(inReplyTo: InReplyTo.Ready) = - when (inReplyTo.content.type) { +private fun textForInReplyTo(inReplyTo: InReplyTo.Ready): String { + val messageContent = inReplyTo.content as? MessageContent ?: return "" + return when (messageContent.type) { is LocationMessageType -> stringResource(CommonStrings.common_shared_location) - else -> inReplyTo.content.body + else -> messageContent.body } +} @Preview @Composable @@ -584,6 +593,7 @@ private fun ContentToPreview() { onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onTimestampClicked = {}, onSwipeToReply = {}, @@ -603,6 +613,7 @@ private fun ContentToPreview() { onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onTimestampClicked = {}, onSwipeToReply = {}, @@ -649,6 +660,7 @@ private fun ContentToPreviewWithReply() { onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onTimestampClicked = {}, onSwipeToReply = {}, @@ -669,6 +681,7 @@ private fun ContentToPreviewWithReply() { onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onTimestampClicked = {}, onSwipeToReply = {}, @@ -725,6 +738,7 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) { onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onTimestampClicked = {}, onSwipeToReply = {}, @@ -764,6 +778,7 @@ private fun ContentWithManyReactionsToPreview() { onUserDataClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onSwipeToReply = {}, onTimestampClicked = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt index 851389c6bd..01800f6348 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt @@ -58,12 +58,12 @@ fun TimelineItemReactionsLayout( SubcomposeLayout(modifier) { constraints -> // Given the placeables and returns a structure representing // how they should wrap on to multiple rows given the constraints max width. - fun calculateRows(measurables: List ): List > { + fun calculateRows(placeables: List
): List > { val rows = mutableListOf
>() var currentRow = mutableListOf
() var rowX = 0 - measurables.forEach { placeable -> + placeables.forEach { placeable -> val horizontalSpacing = if (currentRow.isEmpty()) 0 else itemSpacing.toPx().toInt() // If the current view does not fit on this row bump to the next if (rowX + placeable.width > constraints.maxWidth) { @@ -146,12 +146,18 @@ fun TimelineItemReactionsLayout( } } - val reactionsPlaceables = subcompose(0, reactions).map { it.measure(constraints) } + var reactionsPlaceables = subcompose(0, reactions).map { it.measure(constraints) } if (reactionsPlaceables.isEmpty()) { return@SubcomposeLayout layoutRows(listOf()) } - val addMorePlaceable = subcompose(1, addMoreButton).first().measure(constraints) - val expandPlaceable = subcompose(2, expandButton).first().measure(constraints) + var expandPlaceable = subcompose(1, expandButton).first().measure(constraints) + // Enforce all reaction buttons have the same height + val maxHeight = (reactionsPlaceables + listOf(expandPlaceable)).maxOf { it.height } + val newConstrains = constraints.copy(minHeight = maxHeight) + reactionsPlaceables = subcompose(2, reactions).map { it.measure(newConstrains) } + expandPlaceable = subcompose(3, expandButton).first().measure(newConstrains) + val addMorePlaceable = subcompose(4, addMoreButton).first().measure(newConstrains) + // Calculate the layout of the rows with the reactions button and add more button val reactionsAndAddMore = calculateRows(reactionsPlaceables + listOf(addMorePlaceable)) @@ -185,13 +191,15 @@ internal fun TimelineItemReactionsLayoutPreview() = ElementPreview { content = MessagesReactionsButtonContent.Text( text = stringResource(id = R.string.screen_room_timeline_less_reactions) ), - onClick = { }, + onClick = {}, + onLongClick = {} ) }, addMoreButton = { MessagesReactionButton( content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), - onClick = {} + onClick = {}, + onLongClick = {} ) }, reactions = { @@ -200,7 +208,8 @@ internal fun TimelineItemReactionsLayoutPreview() = ElementPreview { content = MessagesReactionsButtonContent.Reaction( it ), - onClick = {} + onClick = {}, + onLongClick = {} ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index 962d3a2b2b..a5cdf4b6cc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -42,71 +42,83 @@ fun TimelineItemReactions( reactionsState: TimelineItemReactions, isOutgoing: Boolean, onReactionClicked: (emoji: String) -> Unit, + onReactionLongClicked: (emoji: String) -> Unit, onMoreReactionsClicked: () -> Unit, modifier: Modifier = Modifier, ) { var expanded: Boolean by rememberSaveable { mutableStateOf(false) } - - // In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL. - // For RTL languages it should be the opposite. - val reactionsLayoutDirection = if (!isOutgoing) LocalLayoutDirection.current - else if (LocalLayoutDirection.current == LayoutDirection.Ltr) - LayoutDirection.Rtl - else - LayoutDirection.Ltr - - CompositionLocalProvider(LocalLayoutDirection provides reactionsLayoutDirection) { TimelineItemReactionsView( modifier = modifier, reactions = reactionsState.reactions, expanded = expanded, + isOutgoing = isOutgoing, onReactionClick = onReactionClicked, + onReactionLongClick = onReactionLongClicked, onMoreReactionsClick = onMoreReactionsClicked, onToggleExpandClick = { expanded = !expanded }, ) - } } @Composable private fun TimelineItemReactionsView( reactions: ImmutableList , + isOutgoing: Boolean, expanded: Boolean, onReactionClick: (emoji: String) -> Unit, + onReactionLongClick: (emoji: String) -> Unit, onMoreReactionsClick: () -> Unit, onToggleExpandClick: () -> Unit, modifier: Modifier = Modifier -) = TimelineItemReactionsLayout( - modifier = modifier, - itemSpacing = 4.dp, - rowSpacing = 4.dp, - expanded = expanded, - expandButton = { - MessagesReactionButton( - content = MessagesReactionsButtonContent.Text( - text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more) - ), - onClick = onToggleExpandClick, - ) - }, - addMoreButton = { - MessagesReactionButton( - content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), - onClick = onMoreReactionsClick +) { + // In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL. + // For RTL languages it should be the opposite. + val currentLayout = LocalLayoutDirection.current + val reactionsLayoutDirection = if (!isOutgoing) currentLayout + else if (currentLayout == LayoutDirection.Ltr) + LayoutDirection.Rtl + else + LayoutDirection.Ltr + + return CompositionLocalProvider(LocalLayoutDirection provides reactionsLayoutDirection) { + TimelineItemReactionsLayout( + modifier = modifier, + itemSpacing = 4.dp, + rowSpacing = 4.dp, + expanded = expanded, + expandButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more) + ), + onClick = onToggleExpandClick, + onLongClick = {} + ) + }, + addMoreButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), + onClick = onMoreReactionsClick, + onLongClick = {} + ) + }, + reactions = { + reactions.forEach { reaction -> + CompositionLocalProvider(LocalLayoutDirection provides currentLayout) { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction(reaction = reaction), + onClick = { onReactionClick(reaction.key) }, + onLongClick = { onReactionLongClick(reaction.key) } + ) + } + } + } ) - }, - reactions = { - reactions.forEach { reaction -> - MessagesReactionButton( - content = MessagesReactionsButtonContent.Reaction(reaction = reaction), - onClick = { onReactionClick(reaction.key) } - ) - } } -) +} @DayNightPreviews @Composable -fun TimelineItemReactionsViewPreview() = ElementPreview { +internal fun TimelineItemReactionsViewPreview() = ElementPreview { ContentToPreview( reactions = aTimelineItemReactions(count = 1).reactions ) @@ -114,7 +126,7 @@ fun TimelineItemReactionsViewPreview() = ElementPreview { @DayNightPreviews @Composable -fun TimelineItemReactionsViewFewPreview() = ElementPreview { +internal fun TimelineItemReactionsViewFewPreview() = ElementPreview { ContentToPreview( reactions = aTimelineItemReactions(count = 3).reactions ) @@ -122,7 +134,7 @@ fun TimelineItemReactionsViewFewPreview() = ElementPreview { @DayNightPreviews @Composable -fun TimelineItemReactionsViewIncomingPreview() = ElementPreview { +internal fun TimelineItemReactionsViewIncomingPreview() = ElementPreview { ContentToPreview( reactions = aTimelineItemReactions(count = 18).reactions ) @@ -130,7 +142,7 @@ fun TimelineItemReactionsViewIncomingPreview() = ElementPreview { @DayNightPreviews @Composable -fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview { +internal fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview { ContentToPreview( reactions = aTimelineItemReactions(count = 18).reactions, isOutgoing = true @@ -148,6 +160,7 @@ private fun ContentToPreview( ), isOutgoing = isOutgoing, onReactionClicked = {}, + onReactionLongClicked = {}, onMoreReactionsClicked = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt index 70c2a7dc10..d817ec0cd4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -57,7 +57,8 @@ fun CustomReactionBottomSheet( ) { EmojiPicker( onEmojiSelected = ::onEmojiSelectedDismiss, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + selectedEmojis = state.selectedEmoji, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt index b7c210553e..a0d69df372 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt @@ -16,8 +16,8 @@ package io.element.android.features.messages.impl.timeline.components.customreaction -import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.features.messages.impl.timeline.model.TimelineItem sealed interface CustomReactionEvents { - data class UpdateSelectedEvent(val eventId: EventId?) : CustomReactionEvents + data class UpdateSelectedEvent(val event: TimelineItem.Event?) : CustomReactionEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt index 0a23d42085..f094f2dbc6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt @@ -21,22 +21,24 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.collections.immutable.toImmutableSet import javax.inject.Inject class CustomReactionPresenter @Inject constructor() : Presenter { @Composable override fun present(): CustomReactionState { - var selectedEventId by remember { mutableStateOf (null) } + var selectedEvent by remember { mutableStateOf (null) } fun handleEvents(event: CustomReactionEvents) { when (event) { - is CustomReactionEvents.UpdateSelectedEvent -> selectedEventId = event.eventId + is CustomReactionEvents.UpdateSelectedEvent -> selectedEvent = event.event } } - return CustomReactionState(selectedEventId = selectedEventId, eventSink = ::handleEvents) + val selectedEmoji = selectedEvent?.reactionsState?.reactions?.mapNotNull { if(it.isHighlighted) it.key else null }.orEmpty().toImmutableSet() + return CustomReactionState(selectedEventId = selectedEvent?.eventId, selectedEmoji = selectedEmoji, eventSink = ::handleEvents) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt index 6c0c7f3599..9de1642dff 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt @@ -17,8 +17,10 @@ package io.element.android.features.messages.impl.timeline.components.customreaction import io.element.android.libraries.matrix.api.core.EventId +import kotlinx.collections.immutable.ImmutableSet data class CustomReactionState( val selectedEventId: EventId?, + val selectedEmoji: ImmutableSet , val eventSink: (CustomReactionEvents) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt index 3df45eb760..d53e3f1e5b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemContentView.kt @@ -25,6 +25,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent @@ -90,5 +91,10 @@ fun TimelineItemEventContentView( content = content, modifier = modifier ) + is TimelineItemPollContent -> TimelineItemPollView( + content = content, + onAnswerSelected = {}, + modifier = modifier, + ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt new file mode 100644 index 0000000000..db3503be37 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -0,0 +1,53 @@ +/* + * 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.messages.impl.timeline.components.event + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContentProvider +import io.element.android.features.poll.api.ActivePollContentView +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.matrix.api.poll.PollAnswer +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun TimelineItemPollView( + content: TimelineItemPollContent, + onAnswerSelected: (PollAnswer) -> Unit, + modifier: Modifier = Modifier, +) { + ActivePollContentView( + question = content.question, + answerItems = content.answerItems.toImmutableList(), + pollKind = content.pollKind, + onAnswerSelected = onAnswerSelected, + modifier = modifier, + ) +} + +@DayNightPreviews +@Composable +internal fun TimelineItemPollViewPreview(@PreviewParameter(TimelineItemPollContentProvider::class) content: TimelineItemPollContent) = + ElementPreview { + TimelineItemPollView( + content = content, + onAnswerSelected = {}, + ) + } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 65be8f44e0..9066c88182 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -125,7 +125,7 @@ internal fun TimelineItemTextViewDarkPreview(@PreviewParameter(TimelineItemTextB fun ContentToPreview(content: TimelineItemTextBasedContent) { TimelineItemTextView( content = content, - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, extraPadding = ExtraPadding(nbChars = 8), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt index a631e34266..ce853b84b5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt @@ -91,12 +91,12 @@ fun GroupHeaderView( @Preview @Composable -fun GroupHeaderViewLightPreview() = +internal fun GroupHeaderViewLightPreview() = ElementPreviewLight { ContentToPreview() } @Preview @Composable -fun GroupHeaderViewDarkPreview() = +internal fun GroupHeaderViewDarkPreview() = ElementPreviewDark { ContentToPreview() } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt index ecb81a4367..5d07db73a5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -599,5 +600,5 @@ internal fun HtmlDocumentDarkPreview(@PreviewParameter(DocumentProvider::class) @Composable private fun ContentToPreview(document: Document) { - HtmlDocument(document, MutableInteractionSource()) + HtmlDocument(document, remember { MutableInteractionSource() }) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt new file mode 100644 index 0000000000..fdf94f52ce --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.reactionsummary + +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface ReactionSummaryEvents { + object Clear : ReactionSummaryEvents + data class ShowReactionSummary(val eventId: EventId, val reactions: List , val selectedKey: String) : ReactionSummaryEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt new file mode 100644 index 0000000000..456ac5f548 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenter.kt @@ -0,0 +1,87 @@ +/* + * 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.messages.impl.timeline.components.reactionsummary + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject + +class ReactionSummaryPresenter @Inject constructor( + private val room: MatrixRoom, +) : Presenter { + @Composable + override fun present(): ReactionSummaryState { + LaunchedEffect(Unit) { + room.updateMembers() + } + + val membersState by room.membersStateFlow.collectAsState() + + val target: MutableState = remember { + mutableStateOf(null) + } + val targetWithAvatars = populateSenderAvatars(members = membersState.roomMembers().orEmpty().toImmutableList(), summary = target.value) + + fun handleEvents(event: ReactionSummaryEvents) { + when (event) { + is ReactionSummaryEvents.ShowReactionSummary -> target.value = ReactionSummaryState.Summary( + reactions = event.reactions, + selectedKey = event.selectedKey, + selectedEventId = event.eventId + ) + ReactionSummaryEvents.Clear -> target.value = null + } + } + return ReactionSummaryState( + target = targetWithAvatars.value, + eventSink = ::handleEvents + ) + } + + @Composable + private fun populateSenderAvatars(members: ImmutableList , summary: ReactionSummaryState.Summary?) = remember(summary) { + derivedStateOf { + summary?.let { summary -> + summary.copy(reactions = summary.reactions.map { reaction -> + reaction.copy(senders = reaction.senders.map { sender -> + val member = members.firstOrNull { it.userId == sender.senderId } + val user = MatrixUser( + userId = sender.senderId, + displayName = member?.displayName, + avatarUrl = member?.avatarUrl + ) + sender.copy(user = user) + }) + }) + } + } + } + +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt new file mode 100644 index 0000000000..37e150320b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryState.kt @@ -0,0 +1,32 @@ +/* + * 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.messages.impl.timeline.components.reactionsummary + +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.libraries.matrix.api.core.EventId + +data class ReactionSummaryState( + val target: Summary?, + val eventSink: (ReactionSummaryEvents) -> Unit +){ + data class Summary( + val reactions: List , + val selectedKey: String, + val selectedEventId: EventId + ) +} + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt new file mode 100644 index 0000000000..d6642922bb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryStateProvider.kt @@ -0,0 +1,37 @@ +/* + * 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.messages.impl.timeline.components.reactionsummary + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.libraries.matrix.api.core.EventId + +open class ReactionSummaryStateProvider : PreviewParameterProvider { + override val values = sequenceOf(anActionListState()) +} + +fun anActionListState(): ReactionSummaryState { + val reactions = aTimelineItemReactions(8, true).reactions + return ReactionSummaryState( + target = ReactionSummaryState.Summary( + reactions = reactions, + selectedKey = reactions[0].key, + selectedEventId = EventId("$1234"), + ), + eventSink = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt new file mode 100644 index 0000000000..a775fa1856 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt @@ -0,0 +1,275 @@ +/* + * 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.messages.impl.timeline.components.reactionsummary + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +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.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.theme.ElementTheme +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReactionSummaryView( + state: ReactionSummaryState, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + + fun onDismiss() { + state.eventSink(ReactionSummaryEvents.Clear) + } + + if (state.target != null) { + ModalBottomSheet( + onDismissRequest = ::onDismiss, + sheetState = sheetState, + modifier = modifier + ) { + SheetContent(summary = state.target) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SheetContent( + summary: ReactionSummaryState.Summary, + modifier: Modifier = Modifier, +) { + val animationScope = rememberCoroutineScope() + var selectedReactionKey: String by rememberSaveable { mutableStateOf(summary.selectedKey) } + val selectedReactionIndex: Int by remember { + derivedStateOf { + summary.reactions.indexOfFirst { it.key == selectedReactionKey } + } + } + val pagerState = rememberPagerState(initialPage = selectedReactionIndex, pageCount = { summary.reactions.size }) + val reactionListState = rememberLazyListState() + + LaunchedEffect(pagerState.currentPage) { + selectedReactionKey = summary.reactions[pagerState.currentPage].key + val visibleInfo = reactionListState.layoutInfo.visibleItemsInfo + if (selectedReactionIndex <= visibleInfo.first().index || selectedReactionIndex >= visibleInfo.last().index) { + reactionListState.animateScrollToItem(selectedReactionIndex) + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + LazyRow( + state = reactionListState, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, bottom = 12.dp) + ) { + items(summary.reactions) { reaction -> + AggregatedReactionButton( + reaction = reaction, + isHighlighted = selectedReactionKey == reaction.key, + onClick = { + selectedReactionKey = reaction.key + animationScope.launch { + pagerState.animateScrollToPage(selectedReactionIndex) + } + } + ) + } + } + HorizontalPager(state = pagerState) { page -> + LazyColumn(modifier = Modifier.fillMaxHeight()) { + items(summary.reactions[page].senders) { sender -> + + val user = sender.user ?: MatrixUser(userId = sender.senderId) + + SenderRow( + avatarData = user.getAvatarData(AvatarSize.UserListItem), + name = user.displayName ?: user.userId.value, + userId = user.userId.value, + sentTime = sender.sentTime + ) + } + } + } + } +} + +@Composable +fun AggregatedReactionButton( + reaction: AggregatedReaction, + isHighlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + + val buttonColor = if (isHighlighted) { + ElementTheme.colors.bgActionPrimaryRest + } else { + Color.Transparent + } + + val textColor = if (isHighlighted) { + MaterialTheme.colorScheme.inversePrimary + } else { + MaterialTheme.colorScheme.primary + } + + val roundedCornerShape = RoundedCornerShape(corner = CornerSize(percent = 50)) + Surface( + modifier = modifier + .background(buttonColor, roundedCornerShape) + .clip(roundedCornerShape) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 12.dp), + color = buttonColor + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + Text( + text = reaction.displayKey, + style = ElementTheme.typography.fontBodyMdRegular.copy( + fontSize = 20.sp, + lineHeight = 25.sp + ), + ) + if (reaction.count > 1) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = reaction.count.toString(), + color = textColor, + style = ElementTheme.typography.fontBodyMdRegular.copy( + fontSize = 20.sp, + lineHeight = 25.sp + ) + ) + } + } + } +} + +@Composable +fun SenderRow( + avatarData: AvatarData, + name: String, + userId: String, + sentTime: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(avatarData) + Column( + modifier = Modifier.padding(start = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + Text( + modifier = Modifier + .padding(end = 4.dp) + .weight(1f), + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + Text( + text = sentTime, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + Text( + text = userId, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } +} + +@DayNightPreviews +@Composable +internal fun SheetContentPreview( + @PreviewParameter(ReactionSummaryStateProvider::class) state: ReactionSummaryState +) = ElementPreview { + SheetContent(summary = state.target as ReactionSummaryState.Summary) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt deleted file mode 100644 index 9aa3ab5e02..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/CacheInvalidator.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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.messages.impl.timeline.diff - -import androidx.recyclerview.widget.ListUpdateCallback -import io.element.android.features.messages.impl.timeline.model.TimelineItem -import io.element.android.features.messages.impl.timeline.util.invalidateLast -import timber.log.Timber - -internal class CacheInvalidator(private val itemStatesCache: MutableList ) : - ListUpdateCallback { - - override fun onChanged(position: Int, count: Int, payload: Any?) { - Timber.d("onChanged(position= $position, count= $count)") - (position until position + count).forEach { - // Invalidate cache - itemStatesCache[it] = null - } - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - Timber.d("onMoved(fromPosition= $fromPosition, toPosition= $toPosition)") - val model = itemStatesCache.removeAt(fromPosition) - itemStatesCache.add(toPosition, model) - } - - override fun onInserted(position: Int, count: Int) { - Timber.d("onInserted(position= $position, count= $count)") - itemStatesCache.invalidateLast() - repeat(count) { - itemStatesCache.add(position, null) - } - } - - override fun onRemoved(position: Int, count: Int) { - Timber.d("onRemoved(position= $position, count= $count)") - itemStatesCache.invalidateLast() - repeat(count) { - itemStatesCache.removeAt(position) - } - } -} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/TimelineItemsCacheInvalidator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/TimelineItemsCacheInvalidator.kt new file mode 100644 index 0000000000..a7a3bea00e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/TimelineItemsCacheInvalidator.kt @@ -0,0 +1,63 @@ +/* + * 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.messages.impl.timeline.diff + +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator +import io.element.android.libraries.androidutils.diff.DiffCacheInvalidator +import io.element.android.libraries.androidutils.diff.MutableDiffCache + +/** + * [DiffCacheInvalidator] implementation for [TimelineItem]. + * It uses [DefaultDiffCacheInvalidator] and invalidate the cache around the updated item so that those items are computed again. + * This is needed because a timeline item is computed based on the previous and next items. + */ +internal class TimelineItemsCacheInvalidator : DiffCacheInvalidator { + + private val delegate = DefaultDiffCacheInvalidator () + + override fun onChanged(position: Int, count: Int, cache: MutableDiffCache ) { + delegate.onChanged(position, count, cache) + } + + override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache ) { + delegate.onMoved(fromPosition, toPosition, cache) + } + + override fun onInserted(position: Int, count: Int, cache: MutableDiffCache ) { + cache.invalidateAround(position) + delegate.onInserted(position, count, cache) + } + + override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache ) { + cache.invalidateAround(position) + delegate.onRemoved(position, count, cache) + } +} + +/** + * Invalidate the cache around the given position. + * It invalidates the previous and next items. + */ +private fun MutableDiffCache<*>.invalidateAround(position: Int) { + if (position > 0) { + set(position - 1, null) + } + if (position < indices().last) { + set(position + 1, null) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index aa9786c945..8c894bc99a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -19,13 +19,13 @@ package io.element.android.features.messages.impl.timeline.factories import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState -import androidx.recyclerview.widget.DiffUtil -import io.element.android.features.messages.impl.timeline.diff.CacheInvalidator -import io.element.android.features.messages.impl.timeline.diff.MatrixTimelineItemsDiffCallback +import io.element.android.features.messages.impl.timeline.diff.TimelineItemsCacheInvalidator import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemEventFactory import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.androidutils.diff.DiffCacheUpdater +import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import kotlinx.collections.immutable.ImmutableList @@ -35,9 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import timber.log.Timber import javax.inject.Inject -import kotlin.system.measureTimeMillis class TimelineItemsFactory @Inject constructor( private val dispatchers: CoroutineDispatchers, @@ -46,13 +44,20 @@ class TimelineItemsFactory @Inject constructor( private val timelineItemGrouper: TimelineItemGrouper, ) { private val timelineItems = MutableStateFlow(persistentListOf ()) - private val timelineItemsCache = arrayListOf () - - // Items from rust sdk, used for diffing - private var matrixTimelineItems: List = emptyList() private val lock = Mutex() - private val cacheInvalidator = CacheInvalidator(timelineItemsCache) + private val diffCache = MutableListDiffCache () + private val diffCacheUpdater = DiffCacheUpdater ( + diffCache = diffCache, + detectMoves = false, + cacheInvalidator = TimelineItemsCacheInvalidator() + ) { old, new -> + if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) { + old.uniqueId == new.uniqueId + } else { + false + } + } @Composable fun collectItemsAsState(): State > { @@ -63,15 +68,15 @@ class TimelineItemsFactory @Inject constructor( timelineItems: List , ) = withContext(dispatchers.computation) { lock.withLock { - calculateAndApplyDiff(timelineItems) + diffCacheUpdater.updateWith(timelineItems) buildAndEmitTimelineItemStates(timelineItems) } } private suspend fun buildAndEmitTimelineItemStates(timelineItems: List ) { val newTimelineItemStates = ArrayList () - for (index in timelineItemsCache.indices.reversed()) { - val cacheItem = timelineItemsCache[index] + for (index in diffCache.indices().reversed()) { + val cacheItem = diffCache.get(index) if (cacheItem == null) { buildAndCacheItem(timelineItems, index)?.also { timelineItemState -> newTimelineItemStates.add(timelineItemState) @@ -84,21 +89,7 @@ class TimelineItemsFactory @Inject constructor( this.timelineItems.emit(result) } - private fun calculateAndApplyDiff(newTimelineItems: List ) { - val timeToDiff = measureTimeMillis { - val diffCallback = - MatrixTimelineItemsDiffCallback( - oldList = matrixTimelineItems, - newList = newTimelineItems - ) - val diffResult = DiffUtil.calculateDiff(diffCallback, false) - matrixTimelineItems = newTimelineItems - diffResult.dispatchUpdatesTo(cacheInvalidator) - } - Timber.v("Time to apply diff on new list of ${newTimelineItems.size} items: $timeToDiff ms") - } - - private fun buildAndCacheItem( + private suspend fun buildAndCacheItem( timelineItems: List , index: Int ): TimelineItem? { @@ -108,7 +99,7 @@ class TimelineItemsFactory @Inject constructor( is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem) MatrixTimelineItem.Other -> null } - timelineItemsCache[index] = timelineItemState + diffCache[index] = timelineItemState return timelineItemState } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index eb6d0e45c0..b3b2c896c3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -22,6 +22,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent @@ -35,6 +37,8 @@ class TimelineItemContentFactory @Inject constructor( private val messageFactory: TimelineItemContentMessageFactory, private val redactedMessageFactory: TimelineItemContentRedactedFactory, private val stickerFactory: TimelineItemContentStickerFactory, + private val pollFactory: TimelineItemContentPollFactory, + private val pollEndFactory: TimelineItemContentPollEndFactory, private val utdFactory: TimelineItemContentUTDFactory, private val roomMembershipFactory: TimelineItemContentRoomMembershipFactory, private val profileChangeFactory: TimelineItemContentProfileChangeFactory, @@ -43,7 +47,7 @@ class TimelineItemContentFactory @Inject constructor( private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory ) { - fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { + suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent { return when (val itemContent = eventTimelineItem.content) { is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) @@ -53,6 +57,8 @@ class TimelineItemContentFactory @Inject constructor( is RoomMembershipContent -> roomMembershipFactory.create(eventTimelineItem) is StateContent -> stateFactory.create(eventTimelineItem) is StickerContent -> stickerFactory.create(itemContent) + is PollContent -> pollFactory.create(itemContent) + is PollEndContent -> pollEndFactory.create(itemContent) is UnableToDecryptContent -> utdFactory.create(itemContent) is UnknownContent -> TimelineItemUnknownContent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollEndFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollEndFactory.kt new file mode 100644 index 0000000000..ff9eb837b6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollEndFactory.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent +import javax.inject.Inject + +class TimelineItemContentPollEndFactory @Inject constructor() { + + fun create(@Suppress("UNUSED_PARAMETER") content: PollEndContent): TimelineItemEventContent { + return TimelineItemUnknownContent + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt new file mode 100644 index 0000000000..7c61466337 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt @@ -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.messages.impl.timeline.factories.event + +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent +import io.element.android.features.poll.api.PollAnswerItem +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import javax.inject.Inject + +class TimelineItemContentPollFactory @Inject constructor( + private val matrixClient: MatrixClient, + private val featureFlagService: FeatureFlagService, +) { + + suspend fun create(content: PollContent): TimelineItemEventContent { + if (!featureFlagService.isFeatureEnabled(FeatureFlags.Polls)) return TimelineItemUnknownContent + + // Todo Move this computation to the matrix rust sdk + val showResults = content.kind == PollKind.Disclosed && matrixClient.sessionId in content.votes.flatMap { it.value } + val pollVotesCount = content.votes.flatMap { it.value }.size + val userVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys + val answerItems = content.answers.map { answer -> + val votesCount = content.votes[answer.id]?.size ?: 0 + val progress = if (pollVotesCount > 0) votesCount.toFloat() / pollVotesCount.toFloat() else 0f + PollAnswerItem( + answer = answer, + isSelected = answer.id in userVotes, + isDisclosed = showResults, + votesCount = votesCount, + progress = progress, + ) + } + + return TimelineItemPollContent( + question = content.question, + answerItems = answerItems, + votes = content.votes, + pollKind = content.kind, + isDisclosed = showResults + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index 6bc5df1e79..4cb249af72 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.factories.event import io.element.android.features.messages.impl.timeline.groups.canBeDisplayedInBubbleBlock import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.features.messages.impl.timeline.model.AggregatedReactionSender import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions @@ -37,7 +38,7 @@ class TimelineItemEventFactory @Inject constructor( private val matrixClient: MatrixClient, ) { - fun create( + suspend fun create( currentTimelineItem: MatrixTimelineItem.Event, index: Int, timelineItems: List , @@ -90,14 +91,34 @@ class TimelineItemEventFactory @Inject constructor( } private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions { - val aggregatedReactions = event.reactions.map { + val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) + var aggregatedReactions = event.reactions.map { reaction -> + // Sort reactions within an aggregation by timestamp descending. + // This puts the most recent at the top, useful in cases like the + // reaction summary view or getting the most recent reaction. AggregatedReaction( - key = it.key, - count = it.count.toInt(), - isHighlighted = it.senderIds.contains(matrixClient.sessionId), + key = reaction.key, + currentUserId = matrixClient.sessionId, + senders = reaction.senders + .sortedByDescending{ it.timestamp } + .map { + val date = Date(it.timestamp) + AggregatedReactionSender( + senderId = it.senderId, + timestamp = date, + sentTime = timeFormatter.format(date), + ) + } ) } - aggregatedReactions.sortedByDescending { it.count } + // Sort aggregated reactions by count and then timestamp ascending, using + // the most recent reaction in the aggregation(hence index 0). + // This appends new aggregations on the end of the reaction layout. + aggregatedReactions = aggregatedReactions + .sortedWith( + compareByDescending { it.count } + .thenBy { it.senders[0].timestamp } + ) return TimelineItemReactions(aggregatedReactions.toImmutableList()) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 0b8baf692a..1d2dec09b7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRoomMembershipContent @@ -33,6 +34,8 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent @@ -55,6 +58,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean { is TimelineItemVideoContent, is TimelineItemAudioContent, is TimelineItemLocationContent, + is TimelineItemPollContent, TimelineItemRedactedContent, TimelineItemUnknownContent -> false is TimelineItemProfileChangeContent, @@ -74,6 +78,8 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean { is MessageContent, RedactedContent, is StickerContent, + is PollContent, + is PollEndContent, is UnableToDecryptContent -> true is FailedToParseStateContent, is ProfileChangeContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt index ba13896c06..59c52ed8cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReaction.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.timeline.model import io.element.android.libraries.core.extensions.ellipsize +import io.element.android.libraries.matrix.api.core.UserId /** * Length at which we ellipsize a reaction key for display @@ -27,16 +28,15 @@ import io.element.android.libraries.core.extensions.ellipsize private const val MAX_DISPLAY_CHARS = 16 /** + * @property currentUserId the ID of the currently logged in user * @property key the full reaction key (e.g. "👍", "YES!") - * @property count the number of users who reacted with this key - * @property isHighlighted true if the reaction has (also) been sent by the current user. + * @property senders the list of users who sent the reactions */ data class AggregatedReaction( + val currentUserId: UserId, val key: String, - val count: Int, - val isHighlighted: Boolean = false + val senders: List ) { - /** * The key to be displayed on screen. * @@ -45,4 +45,18 @@ data class AggregatedReaction( val displayKey: String by lazy { key.ellipsize(MAX_DISPLAY_CHARS) } + + /** + * The number of users who reacted with this key. + */ + val count: Int by lazy { + senders.count() + } + + /** + * True if the reaction has (also) been sent by the current user. + */ + val isHighlighted: Boolean by lazy { + senders.any { it.senderId.value == currentUserId.value } + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt index 148f565911..dcd6bb105c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt @@ -17,6 +17,9 @@ package io.element.android.features.messages.impl.timeline.model import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.UserId +import java.text.DateFormat +import java.util.Date open class AggregatedReactionProvider : PreviewParameterProvider { override val values: Sequence @@ -29,11 +32,27 @@ open class AggregatedReactionProvider : PreviewParameterProvider + val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) + val date = Date(1_689_061_264L) + add( + AggregatedReactionSender( + senderId = if (isHighlighted && index == 0) userId else UserId("@user$index:server.org"), + timestamp = date, + sentTime = timeFormatter.format(date), + ) + ) + } + } + return AggregatedReaction( + currentUserId = userId, + key = key, + senders = senders + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt new file mode 100644 index 0000000000..276ee0b266 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionSender.kt @@ -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.messages.impl.timeline.model + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import java.util.Date + +data class AggregatedReactionSender( + val senderId: UserId, + val timestamp: Date, + val sentTime: String, + val user: MatrixUser? = null +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index 0ff67e481f..02837bd6b4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -33,3 +33,21 @@ fun TimelineItemEventContent.canBeCopied(): Boolean = is TimelineItemRedactedContent -> true else -> false } + +/** + * Return true if user can react (i.e. send a reaction) on the event content. + */ +fun TimelineItemEventContent.canReact(): Boolean = + when (this) { + is TimelineItemTextBasedContent, + is TimelineItemAudioContent, + is TimelineItemEncryptedContent, + is TimelineItemFileContent, + is TimelineItemImageContent, + is TimelineItemLocationContent, + is TimelineItemPollContent, + is TimelineItemVideoContent -> true + is TimelineItemStateContent, + is TimelineItemRedactedContent, + TimelineItemUnknownContent -> false + } diff --git a/app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt similarity index 52% rename from app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt index 5a641d75c6..b8a2fa8bca 100644 --- a/app/src/main/kotlin/io/element/android/x/initializer/TimberInitializer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt @@ -14,22 +14,18 @@ * limitations under the License. */ -package io.element.android.x.initializer +package io.element.android.features.messages.impl.timeline.model.event -import android.content.Context -import androidx.startup.Initializer -import io.element.android.features.rageshake.impl.logs.VectorFileLogger -import io.element.android.x.BuildConfig -import timber.log.Timber +import io.element.android.features.poll.api.PollAnswerItem +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.poll.PollKind -class TimberInitializer : Initializer { - - override fun create(context: Context) { - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) - } - Timber.plant(VectorFileLogger(context)) - } - - override fun dependencies(): List >> = emptyList() +data class TimelineItemPollContent( + val question: String, + val answerItems: List , + val votes: Map >, + val pollKind: PollKind, + val isDisclosed: Boolean, +) : TimelineItemEventContent { + override val type: String = "TimelineItemPollContent" } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt new file mode 100644 index 0000000000..665d507ead --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt @@ -0,0 +1,39 @@ +/* + * 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.messages.impl.timeline.model.event + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.poll.api.aPollAnswerItemList +import io.element.android.libraries.matrix.api.poll.PollKind + +open class TimelineItemPollContentProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aTimelineItemPollContent(), + aTimelineItemPollContent().copy(isDisclosed = true), + ) +} + +fun aTimelineItemPollContent(): TimelineItemPollContent { + return TimelineItemPollContent( + pollKind = PollKind.Disclosed, + isDisclosed = false, + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(), + votes = emptyMap(), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt index 42c50bbd9d..2b35eeda37 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt @@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemProfileChangeContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent @@ -47,6 +48,7 @@ class MessageSummaryFormatterImpl @Inject constructor( is TimelineItemLocationContent -> context.getString(CommonStrings.common_shared_location) is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt) is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed) + is TimelineItemPollContent, // Todo Polls: handle summary is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event) is TimelineItemImageContent -> context.getString(CommonStrings.common_image) is TimelineItemVideoContent -> context.getString(CommonStrings.common_video) diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml index 1486e35726..84c903844e 100644 --- a/features/messages/impl/src/main/res/values-de/translations.xml +++ b/features/messages/impl/src/main/res/values-de/translations.xml @@ -20,6 +20,6 @@ "Mehr anzeigen" "Erneut senden" "Ihre Nachricht konnte nicht gesendet werden" -"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut." +"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuche es erneut." "Entfernen" - "%1$d changements dans la conversation"
- - "1 de plus"
+- "%1$d de plus"
- "%1$d de plus"
"Appareil photo" @@ -19,6 +19,16 @@"Vous êtes seul dans ce chat" "Message copié" "Vous n‘avez pas le droit de poster dans ce salon" +"Autoriser les paramètres personnalisés" +"Activer cette option remplacera votre paramètre par défaut" +"Me notifier dans ce chat pour" +"paramètres généraux" +"Paramètre par défaut" +"Une erreur s’est produite lors du chargement des paramètres de notification." +"Impossible de restaurer le mode par défaut, veuillez réessayer." +"Impossible de régler le mode, veuillez réessayer." +"Tous les messages" +"Mentions et mots-clés uniquement" "Afficher moins" "Afficher plus" "Renvoyer" diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..7870cbc331 --- /dev/null +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,39 @@ + ++ diff --git a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..d7344e54ca --- /dev/null +++ b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,26 @@ + ++ +- "%1$d изменение в комнате"
+- "%1$d изменения в комнате"
+- "%1$d изменений в комнате"
+"Камера" +"Сделать фото" +"Записать видео" +"Вложение" +"Фото и Видео библиотека" +"Местоположение" +"В настоящее время история сообщений недоступна в этой комнате" +"Не удалось получить данные о пользователе" +"Хотите пригласить их снова?" +"Вы одни в этой комнате" +"Сообщение скопировано" +"У вас нет разрешения публиковать сообщения в этой комнате" +"Разрешить пользовательские настройки" +"Включение этого параметра отменяет настройки по умолчанию" +"Уведомить меня в этом чате" +"Вы можете изменить его в своем %1$s." +"Основные Настройки" +"Настройка по умолчанию" +"Произошла ошибка при загрузке настроек уведомлений." +"Не удалось восстановить режим по умолчанию, попробуйте еще раз." +"Не удалось настроить режим, попробуйте еще раз." +"Все сообщения" +"Только упоминания и ключевые слова" +"Показать меньше" +"Показать больше" +"Отправить снова" +"Не удалось отправить ваше сообщение" +"Добавить эмодзи" +"Показать меньше" +"Не удалось обработать медиафайл для загрузки, попробуйте еще раз." +"Удалить" ++ diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 6303589aa2..c88beff81a 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -13,6 +13,7 @@+ +- "%1$d 個聊天室變更"
++ +- "還有 %1$d 個"
+"照相機" +"拍照" +"錄影" +"附件" +"位置" +"此聊天室只有您一個人" +"訊息已複製" +"您沒有權限在此聊天室傳送訊息" +"全域設定" +"預設" +"無法重設為預設模式,請再試一次。" +"無法設定模式,請再試一次。" +"所有訊息" +"只限提及與關鍵字" +"重傳" +"無法傳送您的訊息" +"移除" +"Attachment" "Photo & Video Library" "Location" +"Poll" "Message history is currently unavailable in this room" "Could not retrieve user details" "Would you like to invite them back?" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 2ff244621d..5fcd06a980 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent @@ -44,8 +45,11 @@ import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.architecture.Async import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.mimetype.MimeTypes +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.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.media.MediaSource @@ -65,6 +69,8 @@ import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.textcomposer.MessageComposerMode +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.test.TestScope @@ -81,9 +87,16 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() + val initialState = consumeItemsUntilTimeout().last() assertThat(initialState.roomId).isEqualTo(A_ROOM_ID) + assertThat(initialState.roomName).isEqualTo(Async.Success("")) + assertThat(initialState.roomAvatar).isEqualTo(Async.Success(AvatarData(id = A_ROOM_ID.value, name = "", size = AvatarSize.TimelineRoom))) + assertThat(initialState.userHasPermissionToSendMessage).isTrue() + assertThat(initialState.userHasPermissionToRedact).isFalse() + assertThat(initialState.hasNetworkConnection).isTrue() + assertThat(initialState.snackbarMessage).isNull() + assertThat(initialState.inviteProgress).isEqualTo(Async.Uninitialized) + assertThat(initialState.showReinvitePrompt).isFalse() } } @@ -132,7 +145,6 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) @@ -177,7 +189,6 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) @@ -314,7 +325,6 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) @@ -328,10 +338,10 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.Dismiss) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } } @@ -342,7 +352,6 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) @@ -363,11 +372,15 @@ class MessagesPresenterTest { assertThat(initialState.showReinvitePrompt).isFalse() // When the input field is focused we show the alert initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true)) - val focusedState = awaitItem() + val focusedState = consumeItemsUntilPredicate { state -> + state.showReinvitePrompt + }.last() assertThat(focusedState.showReinvitePrompt).isTrue() // If it's dismissed then we stop showing the alert initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) - val dismissedState = awaitItem() + val dismissedState = consumeItemsUntilPredicate { state -> + !state.showReinvitePrompt + }.last() assertThat(dismissedState.showReinvitePrompt).isFalse() } } @@ -419,9 +432,7 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() - skipItems(1) + val initialState = consumeItemsUntilTimeout().last() initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) skipItems(1) val loadingState = awaitItem() @@ -448,9 +459,7 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() - skipItems(1) + val initialState = consumeItemsUntilTimeout().last() initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) skipItems(1) val loadingState = awaitItem() @@ -469,9 +478,7 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() - skipItems(1) + val initialState = consumeItemsUntilTimeout().last() initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) skipItems(1) val loadingState = awaitItem() @@ -497,15 +504,16 @@ class MessagesPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() - skipItems(1) + val initialState = consumeItemsUntilTimeout().last() initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) - skipItems(1) - val loadingState = awaitItem() + val loadingState = consumeItemsUntilPredicate { state -> + state.inviteProgress.isLoading() + }.last() assertThat(loadingState.inviteProgress.isLoading()).isTrue() - val newState = awaitItem() - assertThat(newState.inviteProgress.isFailure()).isTrue() + val failureState = consumeItemsUntilPredicate { state -> + state.inviteProgress.isFailure() + }.last() + assertThat(failureState.inviteProgress.isFailure()).isTrue() } } @@ -532,8 +540,22 @@ class MessagesPresenterTest { }.test { // Default value assertThat(awaitItem().userHasPermissionToSendMessage).isTrue() - skipItems(2) + skipItems(1) assertThat(awaitItem().userHasPermissionToSendMessage).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - permission to redact`() = runTest { + val matrixRoom = FakeMatrixRoom(canRedact = true) + val presenter = createMessagePresenter(matrixRoom = matrixRoom) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = consumeItemsUntilPredicate { it.userHasPermissionToRedact }.last() + assertThat(initialState.userHasPermissionToRedact).isTrue() + cancelAndIgnoreRemainingEvents() } } @@ -563,6 +585,7 @@ class MessagesPresenterTest { val buildMeta = aBuildMeta() val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) val customReactionPresenter = CustomReactionPresenter() + val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom) return MessagesPresenter( room = matrixRoom, @@ -570,6 +593,7 @@ class MessagesPresenterTest { timelinePresenter = timelinePresenter, actionListPresenter = actionListPresenter, customReactionPresenter = customReactionPresenter, + reactionSummaryPresenter = reactionSummaryPresenter, retrySendMenuPresenter = retrySendMenuPresenter, networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt index a2baa9dff7..afef9f6730 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -56,7 +56,7 @@ class ActionListPresenterTest { }.test { val initialState = awaitItem() val messageEvent = aMessageEvent(isMine = true, content = TimelineItemRedactedContent) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() @@ -81,7 +81,7 @@ class ActionListPresenterTest { }.test { val initialState = awaitItem() val messageEvent = aMessageEvent(isMine = false, content = TimelineItemRedactedContent) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() @@ -109,7 +109,7 @@ class ActionListPresenterTest { isMine = false, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() @@ -130,6 +130,37 @@ class ActionListPresenterTest { } } + @Test + fun `present - compute for others message and can redact`() = runTest { + val presenter = anActionListPresenter(isBuildDebuggable = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, true)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Copy, + TimelineItemAction.Developer, + TimelineItemAction.ReportContent, + TimelineItemAction.Redact, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + @Test fun `present - compute for my message`() = runTest { val presenter = anActionListPresenter(isBuildDebuggable = true) @@ -141,7 +172,7 @@ class ActionListPresenterTest { isMine = true, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() @@ -174,7 +205,7 @@ class ActionListPresenterTest { isMine = true, content = aTimelineItemImageContent(), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() @@ -205,7 +236,7 @@ class ActionListPresenterTest { isMine = true, content = aTimelineItemStateEventContent(), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() @@ -234,7 +265,7 @@ class ActionListPresenterTest { isMine = true, content = aTimelineItemStateEventContent(), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent, false)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() @@ -262,7 +293,7 @@ class ActionListPresenterTest { isMine = true, content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false) ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) // val loadingState = awaitItem() // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) val successState = awaitItem() @@ -299,10 +330,10 @@ class ActionListPresenterTest { content = TimelineItemRedactedContent, ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent, false)) awaitItem().run { assertThat(target).isEqualTo(ActionListState.Target.None) assertThat(displayEmojiReactions).isFalse() @@ -323,7 +354,7 @@ class ActionListPresenterTest { content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false), ) - initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false)) val successState = awaitItem() assertThat(successState.target).isEqualTo( ActionListState.Target.Success( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt index fb8b3b1948..3608d9e80e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/attachments/AttachmentsPreviewPresenterTest.kt @@ -94,6 +94,21 @@ class AttachmentsPreviewPresenterTest { } } + @Test + fun `present - dismissing the progress dialog stops media upload`() = runTest { + val presenter = anAttachmentsPreviewPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) + initialState.eventSink(AttachmentsPreviewEvents.ClearSendState) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle) + } + } + private fun anAttachmentsPreviewPresenter( localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt index 638c5e0556..8fea1ae155 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt @@ -21,6 +21,8 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseMessageFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentFailedToParseStateFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentMessageFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentPollEndFactory +import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentPollFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentProfileChangeFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentRedactedFactory import io.element.android.features.messages.impl.timeline.factories.event.TimelineItemContentRoomMembershipFactory @@ -35,6 +37,7 @@ import io.element.android.features.messages.impl.timeline.util.FileExtensionExtr import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter import io.element.android.libraries.eventformatter.api.TimelineEventFormatter +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.tests.testutils.testCoroutineDispatchers @@ -42,6 +45,7 @@ import kotlinx.coroutines.test.TestScope internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { val timelineEventFormatter = aTimelineEventFormatter() + val matrixClient = FakeMatrixClient() return TimelineItemsFactory( dispatchers = testCoroutineDispatchers(), eventItemFactory = TimelineItemEventFactory( @@ -49,14 +53,16 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()), redactedMessageFactory = TimelineItemContentRedactedFactory(), stickerFactory = TimelineItemContentStickerFactory(), + pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()), + pollEndFactory = TimelineItemContentPollEndFactory(), utdFactory = TimelineItemContentUTDFactory(), roomMembershipFactory = TimelineItemContentRoomMembershipFactory(timelineEventFormatter), profileChangeFactory = TimelineItemContentProfileChangeFactory(timelineEventFormatter), stateFactory = TimelineItemContentStateFactory(timelineEventFormatter), failedToParseMessageFactory = TimelineItemContentFailedToParseMessageFactory(), - failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory() + failedToParseStateFactory = TimelineItemContentFailedToParseStateFactory(), ), - matrixClient = FakeMatrixClient(), + matrixClient = matrixClient, ), virtualItemFactory = TimelineItemVirtualFactory( daySeparatorFactory = TimelineItemDaySeparatorFactory( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt index 9d19932716..983b251df7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt @@ -24,12 +24,12 @@ import io.element.android.features.messages.impl.forward.ForwardMessagesEvents import io.element.android.features.messages.impl.forward.ForwardMessagesPresenter import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom -import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.aRoomSummaryDetail +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest @@ -76,10 +76,10 @@ class ForwardMessagesPresenterTests { @Test fun `present - update query`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource().apply { + val roomListService = FakeRoomListService().apply { postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetail()))) } - val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val client = FakeMatrixClient(roomListService = roomListService) val presenter = aPresenter(client = client) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -166,11 +166,10 @@ class ForwardMessagesPresenterTests { } } - private fun CoroutineScope.aPresenter( + private fun CoroutineScope.aPresenter( eventId: EventId = AN_EVENT_ID, fakeMatrixRoom: FakeMatrixRoom = FakeMatrixRoom(), coroutineScope: CoroutineScope = this, client: FakeMatrixClient = FakeMatrixClient(), ) = ForwardMessagesPresenter(eventId.value, fakeMatrixRoom, coroutineScope, client) - } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index 5c75c963e5..25b506eb7f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -500,6 +500,23 @@ class MessageComposerPresenterTest { } } + @Test + fun `present - CancelSendAttachment stops media upload`() = runTest { + val presenter = createPresenter(this) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) + val sendingState = awaitItem() + assertThat(sendingState.showAttachmentSourcePicker).isFalse() + assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java) + sendingState.eventSink(MessageComposerEvents.CancelSendAttachment) + assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.None) + } + } + private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) { state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) skipItems(skipCount) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt index 0dfe6fd53c..b4bb14f672 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -24,8 +24,12 @@ import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction +import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom @@ -37,6 +41,7 @@ import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test +import java.util.Date class TimelinePresenterTest { @Test @@ -114,7 +119,7 @@ class TimelinePresenterTest { } @Test - fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest { + fun `present - on scroll finished will not send read receipt if no event is before the index`() = runTest { val timeline = FakeMatrixTimeline( initialTimelineItems = listOf( MatrixTimelineItem.Event(0, anEventTimelineItem()) @@ -188,6 +193,61 @@ class TimelinePresenterTest { } } + @Test + fun `present - reaction ordering`() = runTest { + val timeline = FakeMatrixTimeline() + val presenter = createTimelinePresenter(timeline) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasNewItems).isFalse() + assertThat(initialState.timelineItems.size).isEqualTo(0) + val now = Date().time + val minuteInMilis = 60 * 1000 + // Use index as a convenient value for timestamp + val (alice, bob, charlie) = aMatrixUserList().take(3).mapIndexed { i, user -> + ReactionSender(senderId = user.userId, timestamp = now + i * minuteInMilis) + } + val oneReaction = listOf( + EventReaction( + key = "❤️", + senders = listOf(alice, charlie) + ), + EventReaction( + key = "👍", + senders = listOf(alice, bob) + ), + EventReaction( + key = "🐶", + senders = listOf(charlie) + ), + ) + timeline.updateTimelineItems { + listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(reactions = oneReaction))) + } + skipItems(1) + val item = awaitItem().timelineItems.first() + assertThat(item).isInstanceOf(TimelineItem.Event::class.java) + val event = item as TimelineItem.Event + val reactions = event.reactionsState.reactions + assertThat(reactions.size).isEqualTo(3) + + // Aggregated reactions are sorted by count first and then timestamp ascending(new ones tagged on the end) + assertThat(reactions[0].count).isEqualTo(2) + assertThat(reactions[0].key).isEqualTo("👍") + assertThat(reactions[0].senders[0].senderId).isEqualTo(bob.senderId) + + assertThat(reactions[1].count).isEqualTo(2) + assertThat(reactions[1].key).isEqualTo("❤️") + assertThat(reactions[1].senders[0].senderId).isEqualTo(charlie.senderId) + + assertThat(reactions[2].count).isEqualTo(1) + assertThat(reactions[2].key).isEqualTo("🐶") + assertThat(reactions[2].senders[0].senderId).isEqualTo(charlie.senderId) + } + } + private fun TestScope.createTimelinePresenter( timeline: MatrixTimeline = FakeMatrixTimeline(), timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt index 1c40483ffe..84628cedae 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt @@ -20,6 +20,8 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -38,11 +40,27 @@ class CustomReactionPresenterTests { val initialState = awaitItem() assertThat(initialState.selectedEventId).isNull() - initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(AN_EVENT_ID)) + initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(aTimelineItemEvent(eventId = AN_EVENT_ID))) assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID) initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) assertThat(awaitItem().selectedEventId).isNull() } } + + @Test + fun `present - handle selected emojis`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.selectedEventId).isNull() + val reactions = aTimelineItemReactions(count = 1, isHighlighted = true) + val key = reactions.reactions.first().key + initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions))) + val stateWithSelectedEmojis = awaitItem() + assertThat(stateWithSelectedEmojis.selectedEventId).isEqualTo(AN_EVENT_ID) + assertThat(stateWithSelectedEmojis.selectedEmoji).contains(key) + } + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt new file mode 100644 index 0000000000..0170878cb5 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt @@ -0,0 +1,80 @@ +/* + * 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.messages.timeline.components.reactionsummary + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents +import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryPresenter +import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomMember +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ReactionSummaryPresenterTests { + private val aggregatedReaction = anAggregatedReaction(userId = A_USER_ID, key = "👍", isHighlighted = true) + private val roomMember = aRoomMember(userId = A_USER_ID, avatarUrl = AN_AVATAR_URL, displayName = A_USER_NAME) + private val summaryEvent = ReactionSummaryEvents.ShowReactionSummary(AN_EVENT_ID, listOf(aggregatedReaction), aggregatedReaction.key) + private val room = FakeMatrixRoom().apply { + givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(roomMember))) + } + private val presenter = ReactionSummaryPresenter(room) + + @Test + fun `present - handle showing and hiding the reaction summary`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.target).isEqualTo(null) + + initialState.eventSink(summaryEvent) + assertThat(awaitItem().target).isNotNull() + + initialState.eventSink(ReactionSummaryEvents.Clear) + assertThat(awaitItem().target).isNull() + } + } + + @Test + fun `present - handle reaction summary content and avatars populated`() = runTest { + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.target).isEqualTo(null) + + initialState.eventSink(summaryEvent) + val reactions = awaitItem().target?.reactions + assertThat(reactions?.count()).isEqualTo(1) + assertThat(reactions?.first()?.key).isEqualTo("👍") + assertThat(reactions?.first()?.senders?.first()?.senderId).isEqualTo(A_USER_ID) + assertThat(reactions?.first()?.senders?.first()?.user?.userId).isEqualTo(A_USER_ID) + assertThat(reactions?.first()?.senders?.first()?.user?.avatarUrl).isEqualTo(AN_AVATAR_URL) + assertThat(reactions?.first()?.senders?.first()?.user?.displayName).isEqualTo(A_USER_NAME) + } + } + +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt index 0e1ccbd003..ce107f76aa 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/model/AggregatedReactionTest.kt @@ -16,19 +16,30 @@ package io.element.android.features.messages.timeline.model -import io.element.android.features.messages.impl.timeline.model.AggregatedReaction +import io.element.android.features.messages.impl.timeline.model.anAggregatedReaction import org.junit.Assert.assertEquals import org.junit.Test class AggregatedReactionTest { @Test fun `reaction display key is shortened`() { - val reaction = AggregatedReaction( - key = "1234567890123456790", - count = 1, - isHighlighted = false + val reaction = anAggregatedReaction( + key = "1234567890123456790", + count = 1 ) assertEquals("1234567890123456…", reaction.displayKey) } + + @Test + fun `reaction count and isHighlighted are computed correctly`() { + val reaction = anAggregatedReaction( + key = "1234567890123456790", + count = 3, + isHighlighted = true + ) + + assertEquals(3, reaction.count) + assertEquals(true, reaction.isHighlighted) + } } diff --git a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt index bf05dbc5f5..855bf067dd 100644 --- a/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt +++ b/features/networkmonitor/api/src/main/kotlin/io/element/android/features/networkmonitor/api/ui/ConnectivityIndicatorView.kt @@ -44,8 +44,10 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings @@ -85,14 +87,14 @@ private fun Indicator(modifier: Modifier = Modifier) { .statusBarsPadding() .padding(vertical = 6.dp), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.Bottom, + verticalAlignment = Alignment.CenterVertically, ) { val tint = MaterialTheme.colorScheme.primary Image( imageVector = Icons.Outlined.WifiOff, contentDescription = null, colorFilter = ColorFilter.tint(tint), - modifier = Modifier.size(16.dp), + modifier = Modifier.size(16.sp.toDp()), ) Spacer(modifier = Modifier.width(8.dp)) Text( diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt index 0651d9cc2e..1adfe6bd93 100644 --- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingView.kt @@ -23,10 +23,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.QrCode -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.BiasAlignment @@ -42,9 +40,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.theme.aliasButtonText import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.testtags.TestTags @@ -144,46 +141,27 @@ private fun OnBoardingButtons( } if (state.canLoginWithQrCode) { Button( + text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code), + leadingIcon = IconSource.Vector(Icons.Default.QrCode), onClick = onSignInWithQrCode, - enabled = true, - modifier = Modifier - .fillMaxWidth() - ) { - Icon( - imageVector = Icons.Default.QrCode, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary - ) - Spacer(Modifier.width(14.dp)) - Text( - text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code), - style = ElementTheme.typography.aliasButtonText, - ) - } + modifier = Modifier.fillMaxWidth() + ) } Button( + text = stringResource(id = signInButtonStringRes), onClick = onSignIn, - enabled = true, modifier = Modifier .fillMaxWidth() .testTag(TestTags.onBoardingSignIn) - ) { - Text( - text = stringResource(id = signInButtonStringRes), - style = ElementTheme.typography.aliasButtonText, - ) - } + ) if (state.canCreateAccount) { OutlinedButton( + text = stringResource(id = R.string.screen_onboarding_sign_up), onClick = onCreateAccount, enabled = true, modifier = Modifier .fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.screen_onboarding_sign_up), - style = ElementTheme.typography.aliasButtonText, - ) - } + ) } Spacer(modifier = Modifier.height(16.dp)) } diff --git a/features/onboarding/impl/src/main/res/values-de/translations.xml b/features/onboarding/impl/src/main/res/values-de/translations.xml index e36fa31a2b..82e20c3509 100644 --- a/features/onboarding/impl/src/main/res/values-de/translations.xml +++ b/features/onboarding/impl/src/main/res/values-de/translations.xml @@ -4,7 +4,7 @@ "Mit QR-Code anmelden" "Konto erstellen" "Sicher kommunizieren und zusammenarbeiten" -"Willkommen beim schnellsten Element jemals. Optimiert für Geschwindigkeit und Einfachheit." +"Willkommen beim schnellsten Element aller Zeiten. Optimiert für Geschwindigkeit und Einfachheit." "Willkommen zur %1$s. Verbessert, für Geschwindigkeit und Einfachheit." "Sei in deinem Element" + diff --git a/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml b/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..5150b50c9a --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,8 @@ + +"Вход в систему вручную" +"Войти с помощью QR-кода" +"Создать учетную запись" +"Безопасное общение и совместная работа" +"Добро пожаловать в самый быстрый Element. Преимущество в скорости и простоте." +"Добро пожаловать в %1$s. Supercharged — это скорость и простота." +"Будь в своей стихии" ++ diff --git a/features/poll/api/build.gradle.kts b/features/poll/api/build.gradle.kts new file mode 100644 index 0000000000..be198ba740 --- /dev/null +++ b/features/poll/api/build.gradle.kts @@ -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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.features.poll.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.constraintlayout.compose) + implementation(projects.libraries.matrix.api) + + ksp(libs.showkase.processor) +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/ActivePollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/ActivePollContentView.kt new file mode 100644 index 0000000000..587c3306b1 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/ActivePollContentView.kt @@ -0,0 +1,118 @@ +/* + * 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.poll.api + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.poll.PollAnswer +import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun ActivePollContentView( + question: String, + answerItems: ImmutableList"手動登入" +"使用 QR code 登入" +"建立帳號" +"歡迎使用有史以來最快的 Element。速度超快,操作簡便。" +"得心應手" +, + pollKind: PollKind, + onAnswerSelected: (PollAnswer) -> Unit, + modifier: Modifier = Modifier, +) { + val showResults = answerItems.any { it.isSelected } + Column( + modifier = modifier + .selectableGroup() + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon(imageVector = Icons.Default.BarChart, contentDescription = null) + Text( + text = question, + style = ElementTheme.typography.fontBodyLgMedium + ) + } + + answerItems.forEach { answerItem -> + PollAnswerView( + answerItem = answerItem, + onClick = { onAnswerSelected(answerItem.answer) } + ) + } + + val votesCount = answerItems.sumOf { it.votesCount } + when { + pollKind == PollKind.Undisclosed -> { + Text( + modifier = Modifier + .align(Alignment.Start) + .padding(start = 32.dp), + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + text = stringResource(CommonStrings.common_poll_undisclosed_text), + ) + } + showResults -> { + Text( + modifier = Modifier.align(Alignment.End), + style = ElementTheme.typography.fontBodyXsRegular, + color = ElementTheme.colors.textSecondary, + text = stringResource(CommonStrings.common_poll_total_votes, votesCount), + ) + } + } + } +} + +@DayNightPreviews +@Composable +internal fun ActivePollContentNoResultsPreview() = ElementPreview { + ActivePollContentView( + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(isDisclosed = false), + pollKind = PollKind.Undisclosed, + onAnswerSelected = { }, + ) +} + +@DayNightPreviews +@Composable +internal fun ActivePollContentWithResultsPreview() = ElementPreview { + ActivePollContentView( + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(), + pollKind = PollKind.Disclosed, + onAnswerSelected = { }, + ) +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerItem.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerItem.kt new file mode 100644 index 0000000000..24db33ad1f --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerItem.kt @@ -0,0 +1,36 @@ +/* + * 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.poll.api + +import io.element.android.libraries.matrix.api.poll.PollAnswer + +/** + * UI model for a [PollAnswer]. + * + * @property answer the poll answer. + * @property isSelected whether the user has selected this answer. + * @property isDisclosed whether the votes for this answer should be disclosed. + * @property votesCount the number of votes for this answer. + * @property progress the percentage of votes for this answer. + */ +data class PollAnswerItem( + val answer: PollAnswer, + val isSelected: Boolean, + val isDisclosed: Boolean, + val votesCount: Int, + val progress: Float, +) diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt new file mode 100644 index 0000000000..26fa6fbb71 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerView.kt @@ -0,0 +1,125 @@ +/* + * 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.poll.api + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.selection.selectable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.constraintlayout.compose.Visibility +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.RadioButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonPlurals + +@Suppress("DestructuringDeclarationWithTooManyEntries") // This is necessary to declare the constraints ids +@Composable +fun PollAnswerView( + answerItem: PollAnswerItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ConstraintLayout( + modifier + .wrapContentHeight() + .fillMaxWidth() + .selectable( + selected = answerItem.isSelected, + onClick = onClick, + role = Role.RadioButton, + ) + ) { + val (radioButton, answerText, votesText, progressBar) = createRefs() + RadioButton( + modifier = Modifier.constrainAs(radioButton) { + top.linkTo(answerText.top) + bottom.linkTo(answerText.bottom) + start.linkTo(parent.start) + end.linkTo(answerText.start) + }, + selected = answerItem.isSelected, + onClick = null // null recommended for accessibility with screenreaders + ) + Text( + modifier = Modifier.constrainAs(answerText) { + width = Dimension.fillToConstraints + top.linkTo(parent.top) + start.linkTo(radioButton.end, margin = 8.dp) + end.linkTo(votesText.start) + bottom.linkTo(progressBar.top) + }, + text = answerItem.answer.text, + ) + Text( + modifier = Modifier.constrainAs(votesText) { + start.linkTo(answerText.end) + end.linkTo(parent.end) + bottom.linkTo(answerText.bottom) + visibility = if (answerItem.isDisclosed) Visibility.Visible else Visibility.Gone + }, + text = pluralStringResource( + id = CommonPlurals.common_poll_votes_count, + count = answerItem.votesCount, + answerItem.votesCount + ), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + LinearProgressIndicator( + progress = answerItem.progress, + modifier = Modifier + .constrainAs(progressBar) { + start.linkTo(answerText.start) + end.linkTo(votesText.end) + top.linkTo(answerText.bottom, margin = 10.dp) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints + visibility = if (answerItem.isDisclosed) Visibility.Visible else Visibility.Gone + + }, + strokeCap = StrokeCap.Round, + ) + } +} + +@DayNightPreviews +@Composable +internal fun PollAnswerViewNoResultsPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(), + onClick = { }, + ) +} + +@DayNightPreviews +@Composable +internal fun PollAnswerViewWithResultPreview() = ElementPreview { + PollAnswerView( + answerItem = aPollAnswerItem(isDisclosed = true), + onClick = { } + ) +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerViewProvider.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerViewProvider.kt new file mode 100644 index 0000000000..062d09fd88 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollAnswerViewProvider.kt @@ -0,0 +1,60 @@ +/* + * 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.poll.api + +import io.element.android.libraries.matrix.api.poll.PollAnswer +import kotlinx.collections.immutable.persistentListOf + +fun aPollAnswerItemList(isDisclosed: Boolean = true) = persistentListOf( + aPollAnswerItem( + answer = PollAnswer("option_1", "Italian \uD83C\uDDEE\uD83C\uDDF9"), + isDisclosed = isDisclosed, + votesCount = 5, + progress = 0.5f + ), + aPollAnswerItem( + answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"), + isDisclosed = isDisclosed, + votesCount = 0, + progress = 0f + ), + aPollAnswerItem( + answer = PollAnswer("option_3", "Brazilian \uD83C\uDDE7\uD83C\uDDF7"), + isDisclosed = isDisclosed, + isSelected = true, + votesCount = 1, + progress = 0.1f + ), + aPollAnswerItem(isDisclosed = isDisclosed), +) + +fun aPollAnswerItem( + answer: PollAnswer = PollAnswer( + "option_4", + "French \uD83C\uDDEB\uD83C\uDDF7 But make it a very very very long option then this should just keep expanding" + ), + isSelected: Boolean = false, + isDisclosed: Boolean = true, + votesCount: Int = 4, + progress: Float = 0.4f, +) = PollAnswerItem( + answer = answer, + isSelected = isSelected, + isDisclosed = isDisclosed, + votesCount = votesCount, + progress = progress +) diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollEntryPoint.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollEntryPoint.kt new file mode 100644 index 0000000000..d8f2aed846 --- /dev/null +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/PollEntryPoint.kt @@ -0,0 +1,37 @@ +/* + * 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.poll.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 PollEntryPoint : FeatureEntryPoint { + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + // Add your callbacks + } +} + diff --git a/features/poll/impl/build.gradle.kts b/features/poll/impl/build.gradle.kts new file mode 100644 index 0000000000..626a7d0f2c --- /dev/null +++ b/features/poll/impl/build.gradle.kts @@ -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. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.poll.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + api(projects.features.poll.api) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + + ksp(libs.showkase.processor) +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/DefaultPollEntryPoint.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/DefaultPollEntryPoint.kt new file mode 100644 index 0000000000..052c1bcd5f --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/DefaultPollEntryPoint.kt @@ -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.features.poll.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.poll.api.PollEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultPollEntryPoint @Inject constructor() : PollEntryPoint { + + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PollEntryPoint.NodeBuilder { + val plugins = ArrayList () + + return object : PollEntryPoint.NodeBuilder { + + override fun callback(callback: PollEntryPoint.Callback): PollEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode (buildContext, plugins) + } + } + } +} diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollFlowNode.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollFlowNode.kt new file mode 100644 index 0000000000..9dfeebc692 --- /dev/null +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollFlowNode.kt @@ -0,0 +1,70 @@ +/* + * 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.poll.impl + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.navmodel.backstack.BackStack +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +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 kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class PollFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List , +) : BackstackNode ( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + + sealed interface NavTarget : Parcelable { + @Parcelize + object Root : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + createNode(buildContext) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler(), + ) + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt index b7c2663b72..81075969c6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt @@ -50,12 +50,12 @@ fun AboutView( @Preview @Composable -fun AboutViewLightPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = +internal fun AboutViewLightPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun AboutViewDarkPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = +internal fun AboutViewDarkPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt index 165406c6f5..3ee7365122 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt @@ -46,12 +46,12 @@ fun AnalyticsSettingsView( @Preview @Composable -fun AnalyticsSettingsViewLightPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = +internal fun AnalyticsSettingsViewLightPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun AnalyticsSettingsViewDarkPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = +internal fun AnalyticsSettingsViewDarkPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 1a8216ff1b..010e17bd35 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -110,6 +110,7 @@ class DeveloperSettingsPresenter @Inject constructor( FeatureUiModel( key = feature.key, title = feature.title, + description = feature.description, isEnabled = isEnabled ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 7c5b5d91c8..23ea4faf86 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -96,12 +96,12 @@ fun FeatureListContent( @Preview @Composable -fun DeveloperSettingsViewLightPreview(@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState) = +internal fun DeveloperSettingsViewLightPreview(@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun DeveloperSettingsViewDarkPreview(@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState) = +internal fun DeveloperSettingsViewDarkPreview(@PreviewParameter(DeveloperSettingsStateProvider::class) state: DeveloperSettingsState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 90eade31ab..4b589ad2b0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -24,8 +24,6 @@ import androidx.compose.material.icons.outlined.DeveloperMode import androidx.compose.material.icons.outlined.Help import androidx.compose.material.icons.outlined.InsertChart import androidx.compose.material.icons.outlined.VerifiedUser -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -39,8 +37,9 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.LargeHeightPreview -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.SnackbarHost import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserProvider @@ -65,13 +64,7 @@ fun PreferencesRootView( modifier = modifier, onBackPressed = onBackPressed, title = stringResource(id = CommonStrings.common_settings), - snackbarHost = { - SnackbarHost(snackbarHostState) { data -> - Snackbar( - snackbarData = data, - ) - } - } + snackbarHost = { SnackbarHost(snackbarHostState) } ) { UserPreferences(state.myUser) if (state.showCompleteVerification) { @@ -80,7 +73,7 @@ fun PreferencesRootView( icon = Icons.Outlined.VerifiedUser, onClick = onVerifyClicked, ) - Divider() + HorizontalDivider() } if (state.showAnalyticsSettings) { PreferenceText( @@ -102,7 +95,7 @@ fun PreferencesRootView( if (state.showDeveloperSettings) { DeveloperPreferencesView(onOpenDeveloperSettings) } - Divider() + HorizontalDivider() LogoutPreferenceView( state = state.logoutState, ) @@ -129,12 +122,12 @@ fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) { @LargeHeightPreview @Composable -fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = +internal fun PreferencesRootViewLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreviewLight { ContentToPreview(matrixUser) } @LargeHeightPreview @Composable -fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = +internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreviewDark { ContentToPreview(matrixUser) } @Composable diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt index 73e04fb5d4..3cc2a8211a 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt @@ -68,12 +68,12 @@ fun RageshakePreferencesView( @Preview @Composable -fun RageshakePreferencesViewLightPreview(@PreviewParameter(RageshakePreferencesStateProvider::class) state: RageshakePreferencesState) = +internal fun RageshakePreferencesViewLightPreview(@PreviewParameter(RageshakePreferencesStateProvider::class) state: RageshakePreferencesState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun RageshakePreferencesViewDarkPreview(@PreviewParameter(RageshakePreferencesStateProvider::class) state: RageshakePreferencesState) = +internal fun RageshakePreferencesViewDarkPreview(@PreviewParameter(RageshakePreferencesStateProvider::class) state: RageshakePreferencesState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt index 0af13dcdda..99849ef1d4 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt @@ -16,6 +16,8 @@ package io.element.android.features.rageshake.api.reporter +import java.io.File + interface BugReporter { /** * Send a bug report. @@ -43,4 +45,14 @@ interface BugReporter { customFields: Map ? = null, listener: BugReporterListener? ) + + /** + * Clean the log files if needed to avoid wasting disk space. + */ + fun cleanLogDirectoryIfNeeded() + + /** + * Provide the log directory. + */ + fun logDirectory(): File } diff --git a/features/rageshake/api/src/main/res/values-ru/translations.xml b/features/rageshake/api/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..6cb17a3401 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ru/translations.xml @@ -0,0 +1,5 @@ + + + diff --git a/features/rageshake/impl/build.gradle.kts b/features/rageshake/impl/build.gradle.kts index 3283e3f37a..464d521689 100644 --- a/features/rageshake/impl/build.gradle.kts +++ b/features/rageshake/impl/build.gradle.kts @@ -32,6 +32,7 @@ anvil { dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) + implementation(projects.services.toolbox.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.network) diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt index 74f3a13d13..32d45b29ff 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -47,8 +48,8 @@ import io.element.android.libraries.designsystem.components.preferences.Preferen import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground -import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.OutlinedTextField import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.LogCompositions @@ -97,6 +98,7 @@ fun BugReportView( eventSink(BugReportEvents.SetDescription(it)) }, keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, keyboardType = KeyboardType.Text, imeAction = ImeAction.Next ), @@ -147,14 +149,13 @@ fun BugReportView( // Submit PreferenceRow { Button( + text = stringResource(id = CommonStrings.action_send), onClick = { eventSink(BugReportEvents.SendBugReport) }, enabled = state.submitEnabled, modifier = Modifier .fillMaxWidth() .padding(top = 24.dp, bottom = 16.dp) - ) { - Text(text = stringResource(id = CommonStrings.action_send)) - } + ) } } @@ -176,11 +177,11 @@ fun BugReportView( @Preview @Composable -fun BugReportViewLightPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreviewLight { ContentToPreview(state) } +internal fun BugReportViewLightPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun BugReportViewDarkPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreviewDark { ContentToPreview(state) } +internal fun BugReportViewDarkPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreviewDark { ContentToPreview(state) } @Composable private fun ContentToPreview(state: BugReportState) { diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt index a5e7edf405..b71c8af372 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt @@ -62,9 +62,9 @@ class VectorUncaughtExceptionHandler( totalSize = info.totalMemory() usedSize = totalSize - freeSize } - append("usedSize " + usedSize / 1048576L + " MB\n") - append("freeSize " + freeSize / 1048576L + " MB\n") - append("totalSize " + totalSize / 1048576L + " MB\n") + append("usedSize " + usedSize / 1_048_576L + " MB\n") + append("freeSize " + freeSize / 1_048_576L + " MB\n") + append("totalSize " + totalSize / 1_048_576L + " MB\n") append("Thread: ") append(thread.name) append(", Exception: ") diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index 65dc48aca8..a8491b5a74 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -18,6 +18,7 @@ package io.element.android.features.rageshake.impl.reporter import android.content.Context import android.os.Build +import android.text.format.DateUtils.DAY_IN_MILLIS import androidx.core.net.toFile import androidx.core.net.toUri import com.squareup.anvil.annotations.ContributesBinding @@ -27,10 +28,10 @@ import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.ReportType import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder import io.element.android.features.rageshake.impl.R -import io.element.android.features.rageshake.impl.logs.VectorFileLogger import io.element.android.libraries.androidutils.file.compressFile import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.toOnOff import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.mimetype.MimeTypes @@ -38,7 +39,10 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.Call import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -65,6 +69,8 @@ class DefaultBugReporter @Inject constructor( @ApplicationContext private val context: Context, private val screenshotHolder: ScreenshotHolder, private val crashDataStore: CrashDataStore, + private val coroutineScope: CoroutineScope, + private val systemClock: SystemClock, private val coroutineDispatchers: CoroutineDispatchers, private val okHttpClient: Provider"При последнем использовании %1$s произошел сбой. Хотите поделиться отчетом о сбое?" +"Кажется, вы трясли телефон. Хотите открыть экран отчета об ошибке?" +, private val userAgentProvider: UserAgentProvider, @@ -87,6 +93,7 @@ class DefaultBugReporter @Inject constructor( // filenames private const val LOG_CAT_ERROR_FILENAME = "logcatError.log" private const val LOG_CAT_FILENAME = "logcat.log" + private const val LOG_DIRECTORY_NAME = "logs" // private const val KEY_REQUESTS_FILENAME = "keyRequests.log" private const val BUFFER_SIZE = 1024 * 1024 * 50 @@ -158,9 +165,8 @@ class DefaultBugReporter @Inject constructor( val gzippedFiles = ArrayList () - val vectorFileLogger = VectorFileLogger.getFromTimber() - if (withDevicesLogs && vectorFileLogger != null) { - val files = vectorFileLogger.getLogFiles() + if (withDevicesLogs) { + val files = getLogFiles() files.mapNotNullTo(gzippedFiles) { f -> if (!mIsCancelled) { compressFile(f) @@ -168,6 +174,7 @@ class DefaultBugReporter @Inject constructor( null } } + files.deleteAllExceptMostRecent() } if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) { @@ -458,6 +465,54 @@ class DefaultBugReporter @Inject constructor( ) } + override fun logDirectory(): File { + return File(context.cacheDir, LOG_DIRECTORY_NAME) + } + + override fun cleanLogDirectoryIfNeeded() { + coroutineScope.launch(coroutineDispatchers.io) { + // delete the log files older than 1 day, except the most recent one + deleteOldLogFiles(systemClock.epochMillis() - DAY_IN_MILLIS) + } + } + + /** + * @return the files on the log directory. + */ + private fun getLogFiles(): List { + return tryOrNull( + onError = { Timber.e(it, "## getLogFiles() failed") } + ) { + val logDirectory = logDirectory() + logDirectory.listFiles()?.toList() + }.orEmpty() + } + + /** + * Delete the log files older than the given time except the most recent one. + * @param time the time in ms + */ + private fun deleteOldLogFiles(time: Long) { + val logFiles = getLogFiles() + val oldLogFiles = logFiles.filter { it.lastModified() < time } + oldLogFiles.deleteAllExceptMostRecent() + } + + /** + * Delete all the log files except the most recent one. + * + */ + private fun List .deleteAllExceptMostRecent() { + if (size > 1) { + val mostRecentFile = maxByOrNull { it.lastModified() } + forEach { file -> + if (file != mostRecentFile) { + file.safeDelete() + } + } + } + } + // ============================================================================================================== // Logcat management // ============================================================================================================== @@ -485,6 +540,10 @@ class DefaultBugReporter @Inject constructor( Timber.e(error, "## saveLogCat() : fail to write logcat OOM") } catch (e: Exception) { Timber.e(e, "## saveLogCat() : fail to write logcat") + } finally { + if (logCatErrFile.exists()) { + logCatErrFile.safeDelete() + } } return null diff --git a/features/rageshake/impl/src/main/res/values-de/translations.xml b/features/rageshake/impl/src/main/res/values-de/translations.xml index a24318545d..b316d8b45e 100644 --- a/features/rageshake/impl/src/main/res/values-de/translations.xml +++ b/features/rageshake/impl/src/main/res/values-de/translations.xml @@ -1,14 +1,14 @@ "Bildschirmfoto anhängen" -"Sie können mich kontaktieren, wenn Sie weitere Fragen haben" +"Ihr könnt mich kontaktieren, wenn ihr weitere Fragen habt" "Kontaktiere mich" "Bildschirmfoto bearbeiten" "Beschreibe bitte den Fehler. Was hast du gemacht? Was hätte passieren sollen? Was ist passiert? Bitte beschreibe alles mit so vielen Details wie möglich." "Beschreibe den Fehler…" -"Wenn möglich, verfassen Sie die Beschreibung bitte auf Englisch." +"Wenn möglich, verfasse die Beschreibung bitte auf Englisch." "Absturzprotokolle senden" -"Senden Sie Protokolle, um zu helfen" +"Logs zulassen" "Bildschirmfoto senden" "Um zu überprüfen, ob alles wie vorgesehen funktioniert, werden Protokolle mit deiner Nachricht gesendet. Diese werden privat sein. Um nur Ihre Nachricht zu senden, schalte diese Einstellung aus." "%1$s ist bei der letzten Verwendung abgestürzt. Möchtest du uns einen Absturzbericht senden?" diff --git a/features/rageshake/impl/src/main/res/values-fr/translations.xml b/features/rageshake/impl/src/main/res/values-fr/translations.xml index bf6ad2d215..53b95af6be 100644 --- a/features/rageshake/impl/src/main/res/values-fr/translations.xml +++ b/features/rageshake/impl/src/main/res/values-fr/translations.xml @@ -1,7 +1,7 @@"Joindre une capture d\'écran" -"Vous pouvez me contacter si vous avez des questions complémentaires" +"Vous pouvez me contacter si vous avez des questions complémentaires." "Me contacter" "Modifier la capture d\'écran" "S\'il vous plait, veuillez décrire le bogue. Qu\'avez-vous fait ? À quoi vous attendiez-vous ? Que s\'est-il réellement passé. Veuillez ajouter le plus de détails possible." diff --git a/features/rageshake/impl/src/main/res/values-ru/translations.xml b/features/rageshake/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..8f05a3148d --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,15 @@ + ++ diff --git a/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml b/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..6e9eaabed3 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,7 @@ + +"Приложить снимок экрана" +"Вы можете связаться со мной, если у Вас возникнут какие-либо дополнительные вопросы." +"Связаться со мной" +"Редактировать снимок экрана" +"Пожалуйста, опишите ошибку. Что вы сделали? Что вы ожидали, что произойдет? Что произошло на самом деле. Пожалуйста, опишите все как можно подробнее." +"Опишите ошибку…" +"Если возможно, пожалуйста, напишите описание на английском языке." +"Отправка журналов сбоев" +"Разрешить ведение журналов" +"Отправить снимок экрана" +"Чтобы убедиться, что все работает правильно, в сообщение будут включены журналы. Чтобы отправить сообщение без журналов, отключите эту настройку." +"При последнем использовании %1$s произошел сбой. Хотите поделиться отчетом о сбое?" ++ diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt index ac8940a1ac..82edaf563d 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt @@ -21,6 +21,7 @@ import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.api.reporter.ReportType import io.element.android.libraries.matrix.test.A_FAILURE_REASON import kotlinx.coroutines.delay +import java.io.File class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { override suspend fun sendBugReport( @@ -55,6 +56,14 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes delay(100) listener?.onUploadSucceed(null) } + + override fun cleanLogDirectoryIfNeeded() { + // No op + } + + override fun logDirectory(): File { + return File("fake") + } } enum class FakeBugReporterMode { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 63868c8227..51754ca6de 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -17,13 +17,11 @@ package io.element.android.features.roomdetails.impl import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -49,6 +47,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.leaveroom.api.LeaveRoomView @@ -68,7 +67,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.LargeHeightPreview import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem -import io.element.android.libraries.designsystem.theme.components.DropdownMenuItemText 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 @@ -196,7 +194,7 @@ internal fun RoomDetailsTopBar( onDismissRequest = { showMenu = false }, ) { DropdownMenuItem( - text = { DropdownMenuItemText(stringResource(id = CommonStrings.action_edit)) }, + text = { Text(stringResource(id = CommonStrings.action_edit)) }, onClick = { // Explicitly close the menu before handling the action, as otherwise it stays open during the // transition and renders really badly. @@ -225,18 +223,30 @@ internal fun RoomHeaderSection( roomAlias: String?, modifier: Modifier = Modifier ) { - Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Box(modifier = Modifier.size(70.dp)) { - Avatar( - avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.RoomHeader), - modifier = Modifier.fillMaxSize() - ) - } + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar( + avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.RoomHeader), + modifier = Modifier.size(70.dp) + ) Spacer(modifier = Modifier.height(24.dp)) - Text(roomName, style = ElementTheme.typography.fontHeadingLgBold) + Text( + text = roomName, + style = ElementTheme.typography.fontHeadingLgBold, + textAlign = TextAlign.Center, + ) if (roomAlias != null) { Spacer(modifier = Modifier.height(6.dp)) - Text(roomAlias, style = ElementTheme.typography.fontBodyLgRegular, color = MaterialTheme.colorScheme.secondary) + Text( + text = roomAlias, + style = ElementTheme.typography.fontBodyLgRegular, + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Center, + ) } Spacer(Modifier.height(32.dp)) } @@ -321,12 +331,12 @@ internal fun OtherActionsSection(onLeaveRoom: () -> Unit, modifier: Modifier = M @LargeHeightPreview @Composable -fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = +internal fun RoomDetailsLightPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = ElementPreviewLight { ContentToPreview(state) } @LargeHeightPreview @Composable -fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = +internal fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt index 029f5ac5df..cd0cbf878e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ModalBottomSheetValue @@ -52,6 +53,7 @@ import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -66,7 +68,6 @@ import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.aliasButtonText import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Scaffold @@ -113,17 +114,13 @@ fun RoomDetailsEditView( navigationIcon = { BackButton(onClick = onBackPressed) }, actions = { TextButton( + text = stringResource(CommonStrings.action_save), enabled = state.saveButtonEnabled, onClick = { focusManager.clearFocus() state.eventSink(RoomDetailsEditEvents.Save) }, - ) { - Text( - text = stringResource(CommonStrings.action_save), - style = ElementTheme.typography.aliasButtonText, - ) - } + ) } ) }, @@ -164,6 +161,9 @@ fun RoomDetailsEditView( placeholder = stringResource(CommonStrings.common_topic_placeholder), maxLines = 10, onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(it)) }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), ) } else { LabelledReadOnlyField( @@ -287,12 +287,12 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = @Preview @Composable -fun RoomDetailsEditViewLightPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = +internal fun RoomDetailsEditViewLightPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun RoomDetailsEditViewDarkPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = +internal fun RoomDetailsEditViewDarkPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt index 555f04af20..601830808f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt @@ -38,7 +38,7 @@ 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.aliasScreenTitle -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBarResultState @@ -128,10 +128,8 @@ fun RoomInviteMembersTopBar( navigationIcon = { BackButton(onClick = onBackPressed) }, actions = { TextButton( + text = stringResource(CommonStrings.action_send), onClick = onSendPressed, - content = { - Text(stringResource(CommonStrings.action_send)) - }, enabled = canSend, ) } @@ -210,7 +208,7 @@ private fun RoomInviteMembersSearchBar( } if (index < results.lastIndex) { - Divider() + HorizontalDivider() } } } @@ -220,12 +218,12 @@ private fun RoomInviteMembersSearchBar( @Preview @Composable -fun RoomInviteMembersLightPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = +internal fun RoomInviteMembersLightPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun RoomInviteMembersDarkPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = +internal fun RoomInviteMembersDarkPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index 3bfde66c06..b0bac12d4e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -45,7 +45,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.aliasButtonText import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Scaffold @@ -212,14 +211,9 @@ private fun RoomMemberListTopBar( actions = { if (canInvite) { TextButton( - modifier = Modifier.padding(horizontal = 8.dp), + text = stringResource(CommonStrings.action_invite), onClick = onInvitePressed, - ) { - Text( - text = stringResource(CommonStrings.action_invite), - style = ElementTheme.typography.aliasButtonText, - ) - } + ) } } ) @@ -257,12 +251,12 @@ private fun RoomMemberSearchBar( @Preview @Composable -fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = +internal fun RoomMemberListLightPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = +internal fun RoomMemberListDarkPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt index 72b9d4c20c..84dd9319cf 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs @@ -118,10 +119,16 @@ internal fun RoomMemberHeaderSection( } Spacer(modifier = Modifier.height(24.dp)) if (userName != null) { - Text(userName, style = ElementTheme.typography.fontHeadingLgBold) + Text(text = userName, style = ElementTheme.typography.fontHeadingLgBold) Spacer(modifier = Modifier.height(6.dp)) } - Text(userId, style = ElementTheme.typography.fontBodyLgRegular, color = MaterialTheme.colorScheme.secondary) + Text( + text = userId, + style = ElementTheme.typography.fontBodyLgRegular, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + textAlign = TextAlign.Center, + ) Spacer(Modifier.height(40.dp)) } } @@ -146,12 +153,12 @@ internal fun SendMessageSection(onSendMessage: () -> Unit, modifier: Modifier = @LargeHeightPreview @Composable -fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = +internal fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = ElementPreviewLight { ContentToPreview(state) } @LargeHeightPreview @Composable -fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = +internal fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml index ee34445805..2696cc99ea 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -1,7 +1,7 @@"附上螢幕截圖" +"聯絡我" +"編輯螢幕截圖" +"傳送螢幕截圖" +- - "1 membre"
+- "%1$d membre"
- "%1$d membres"
"Définir un sujet" @@ -12,17 +12,22 @@"Impossible de mettre à jour le salon" "Les messages sont sécurisés par des cadenas numériques. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller." "Chiffrement des messages activé" +"Une erreur s’est produite lors du chargement des paramètres de notification." +"Impossible de désactiver les notifications de cette salle, veuillez réessayer." +"Impossible de réactiver les notifications de cette salle, veuillez réessayer." "Inviter des personnes" +"Personnalisé" +"Par défaut" "Notifications" "Nom du salon" "Partager le salon" "Mise à jour du salon…" "En attente" "Bloquer" -"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez annuler cette action à tout moment." +"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez les débloquer à tout moment." "Bloquer l\'utilisateur" "Débloquer" -"Lorsque vous débloquez l\'utilisateur, vous pourrez à nouveau voir tous leur messages." +"Vous pourrez à nouveau voir tous leurs messages." "Débloquer l\'utilisateur" "Quitter le salon" "Personnes" diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..4d2664ab30 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,38 @@ + ++ diff --git a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..cbac73f938 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,27 @@ + ++ +- "%1$d пользователь"
+- "%1$d пользователя"
+- "%1$d пользователей"
+"Добавить тему" +"Уже зарегистрирован" +"Уже приглашены" +"Редактировать комнату" +"Произошла неизвестная ошибка, и информацию нельзя было изменить." +"Не удалось обновить комнату" +"Сообщения зашифрованы. Только у вас и у получателей есть уникальные ключи для их разблокировки." +"Шифрование сообщений включено" +"При загрузке настроек уведомлений произошла ошибка." +"Не удалось отключить звук в этой комнате, попробуйте еще раз." +"Не удалось включить звук в эту комнату, попробуйте еще раз." +"Пригласить участника" +"Пользовательский" +"По умолчанию" +"Уведомления" +"Название комнаты" +"Поделиться комнатой" +"Обновление комнаты…" +"В ожидании" +"Участники комнаты" +"Заблокировать" +"Заблокированные пользователи не смогут отправлять вам сообщения, а все их сообщения будут скрыты. Вы можете разблокировать их в любое время." +"Заблокировать пользователя" +"Разблокировать" +"Вы снова сможете увидеть все сообщения." +"Разблокировать пользователя" +"Покинуть комнату" +"Пользователи" +"Безопасность" +"Тема" ++ diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index 158ba386b4..b1f67dab1e 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -1,7 +1,7 @@+ +- "%1$d 位夥伴"
+"新增主題" +"已是成員" +"已邀請" +"編輯聊天室" +"訊息已加密" +"邀請夥伴" +"自訂" +"預設" +"通知" +"聊天室名稱" +"分享聊天室" +"正在更新聊天室…" +"待定" +"聊天室成員" +"封鎖" +"封鎖使用者" +"解除封鎖" +"解除封鎖使用者" +"離開聊天室" +"夥伴" +"主題" +- - "1 person"
+- "%1$d person"
- "%1$d people"
"Add topic" diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 5a82b20723..dbef9c799b 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -135,6 +135,6 @@ class RoomListPresenter @Inject constructor( // Safe to give bigger size than room list val extendedRangeEnd = range.last + midExtendedRangeSize val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd) - client.roomSummaryDataSource.updateAllRoomsVisibleRange(extendedRange) + client.roomListService.updateAllRoomsVisibleRange(extendedRange) } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 657648df42..176962ca2a 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -28,8 +28,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -54,11 +52,12 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.search.RoomListSearchResultView import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.utils.LogCompositions +import io.element.android.libraries.designsystem.utils.SnackbarHost import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.designsystem.R as DrawableR @@ -208,7 +207,7 @@ fun RoomListContent( onLongClick = onRoomLongClicked, ) if (index != state.roomList.lastIndex) { - Divider() + HorizontalDivider() } } } @@ -227,13 +226,7 @@ fun RoomListContent( ) } }, - snackbarHost = { - SnackbarHost(snackbarHostState) { data -> - Snackbar( - snackbarData = data, - ) - } - }, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt index 16eea28f20..9d6b54366a 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RequestVerificationHeader.kt @@ -19,7 +19,6 @@ package io.element.android.features.roomlist.impl.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -37,7 +36,7 @@ import androidx.compose.ui.unit.dp import io.element.android.features.roomlist.impl.R import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface @@ -83,15 +82,11 @@ internal fun RequestVerificationHeader( ) Spacer(modifier = Modifier.height(12.dp)) Button( + text = stringResource(CommonStrings.action_continue), + size = ButtonSize.Medium, modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 7.dp), onClick = onVerifyClicked, - ) { - Text( - stringResource(CommonStrings.action_continue), - style = ElementTheme.typography.aliasButtonText - ) - } + ) } } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt index db0ea8c11d..001c048e4e 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt @@ -43,10 +43,11 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.text.applyScaleDown +import io.element.android.libraries.designsystem.text.toSp import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem -import io.element.android.libraries.designsystem.theme.components.DropdownMenuItemText 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.MediumTopAppBar @@ -114,7 +115,11 @@ private fun DefaultRoomListTopBar( val fontStyle = if (scrollBehavior.state.collapsedFraction > 0.5) ElementTheme.typography.aliasScreenTitle else - ElementTheme.typography.fontHeadingLgBold + ElementTheme.typography.fontHeadingLgBold.copy( + // Due to a limitation of MediumTopAppBar, and to avoid the text to be truncated, + // ensure that the font size will never be bigger than 28.dp. + fontSize = 28.dp.applyScaleDown().toSp() + ) Text( style = fontStyle, text = stringResource(id = R.string.screen_roomlist_main_space_title) @@ -163,7 +168,7 @@ private fun DefaultRoomListTopBar( showMenu = false onMenuActionClicked(RoomListMenuAction.InviteFriends) }, - text = { DropdownMenuItemText(stringResource(id = CommonStrings.action_invite)) }, + text = { Text(stringResource(id = CommonStrings.action_invite)) }, leadingIcon = { Icon( Icons.Outlined.Share, @@ -177,7 +182,7 @@ private fun DefaultRoomListTopBar( showMenu = false onMenuActionClicked(RoomListMenuAction.ReportBug) }, - text = { DropdownMenuItemText(stringResource(id = CommonStrings.common_report_a_bug)) }, + text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) }, leadingIcon = { Icon( Icons.Outlined.BugReport, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt index 3a89014799..e05efd3ddd 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSource.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.coroutines.withContext import javax.inject.Inject @@ -44,8 +44,9 @@ class DefaultInviteStateDataSource @Inject constructor( @Composable override fun inviteState(): InvitesState { val invites by client - .roomSummaryDataSource - .inviteRooms() + .roomListService + .invites() + .summaries .collectAsState() val seenInvites by seenInvitesStore diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt index 8602f15910..e44bcd6b6b 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/datasource/RoomListDataSource.kt @@ -18,6 +18,8 @@ package io.element.android.features.roomlist.impl.datasource import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders +import io.element.android.libraries.androidutils.diff.DiffCacheUpdater +import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter @@ -25,8 +27,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomSummary -import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -36,11 +38,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject class RoomListDataSource @Inject constructor( - private val roomSummaryDataSource: RoomSummaryDataSource, + private val roomListService: RoomListService, private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, private val roomLastMessageFormatter: RoomLastMessageFormatter, private val coroutineDispatchers: CoroutineDispatchers, @@ -50,15 +54,18 @@ class RoomListDataSource @Inject constructor( private val _allRooms = MutableStateFlow>(persistentListOf()) private val _filteredRooms = MutableStateFlow >(persistentListOf()) + private val lock = Mutex() + private val diffCache = MutableListDiffCache () + private val diffCacheUpdater = DiffCacheUpdater (diffCache = diffCache, detectMoves = true) { old, new -> + old?.identifier() == new?.identifier() + } + fun launchIn(coroutineScope: CoroutineScope) { - roomSummaryDataSource + roomListService .allRooms() + .summaries .onEach { roomSummaries -> - _allRooms.value = if (roomSummaries.isEmpty()) { - RoomListRoomSummaryPlaceholders.createFakeList(16) - } else { - mapRoomSummaries(roomSummaries) - }.toImmutableList() + replaceWith(roomSummaries) } .launchIn(coroutineScope) @@ -73,7 +80,8 @@ class RoomListDataSource @Inject constructor( } .onEach { _filteredRooms.value = it - }.launchIn(coroutineScope) + } + .launchIn(coroutineScope) } fun updateFilter(filterValue: String) { @@ -84,33 +92,63 @@ class RoomListDataSource @Inject constructor( val allRooms: StateFlow > = _allRooms val filteredRooms: StateFlow > = _filteredRooms - private suspend fun mapRoomSummaries( - roomSummaries: List - ): List = withContext(coroutineDispatchers.computation) { - roomSummaries.map { roomSummary -> - when (roomSummary) { - is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier) - is RoomSummary.Filled -> { - val avatarData = AvatarData( - id = roomSummary.identifier(), - name = roomSummary.details.name, - url = roomSummary.details.avatarURLString, - size = AvatarSize.RoomListItem, - ) - val roomIdentifier = roomSummary.identifier() - RoomListRoomSummary( - id = roomSummary.identifier(), - roomId = RoomId(roomIdentifier), - name = roomSummary.details.name, - hasUnread = roomSummary.details.unreadNotificationCount > 0, - timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp), - lastMessage = roomSummary.details.lastMessage?.let { message -> - roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect) - }.orEmpty(), - avatarData = avatarData, - ) + private suspend fun replaceWith(roomSummaries: List ) = withContext(coroutineDispatchers.computation) { + lock.withLock { + diffCacheUpdater.updateWith(roomSummaries) + buildAndEmitAllRooms(roomSummaries) + } + } + + private suspend fun buildAndEmitAllRooms(roomSummaries: List ) { + if (diffCache.isEmpty()) { + _allRooms.emit( + RoomListRoomSummaryPlaceholders.createFakeList(16).toImmutableList() + ) + } else { + val roomListRoomSummaries = ArrayList () + for (index in diffCache.indices()) { + val cacheItem = diffCache.get(index) + if (cacheItem == null) { + buildAndCacheItem(roomSummaries, index)?.also { timelineItemState -> + roomListRoomSummaries.add(timelineItemState) + } + } else { + roomListRoomSummaries.add(cacheItem) } } + _allRooms.emit(roomListRoomSummaries.toImmutableList()) + } + } + + private fun buildAndCacheItem( + roomSummaries: List , + index: Int + ): RoomListRoomSummary? { + val roomListRoomSummary = when (val roomSummary = roomSummaries.getOrNull(index)) { + is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier) + is RoomSummary.Filled -> { + val avatarData = AvatarData( + id = roomSummary.identifier(), + name = roomSummary.details.name, + url = roomSummary.details.avatarURLString, + size = AvatarSize.RoomListItem, + ) + val roomIdentifier = roomSummary.identifier() + RoomListRoomSummary( + id = roomSummary.identifier(), + roomId = RoomId(roomIdentifier), + name = roomSummary.details.name, + hasUnread = roomSummary.details.unreadNotificationCount > 0, + timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp), + lastMessage = roomSummary.details.lastMessage?.let { message -> + roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect) + }.orEmpty(), + avatarData = avatarData, + ) + } + null -> null } + diffCache[index] = roomListRoomSummary + return roomListRoomSummary } } diff --git a/features/roomlist/impl/src/main/res/values-ru/translations.xml b/features/roomlist/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..fcd91c56e3 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/roomlist/impl/src/main/res/values-sk/translations.xml b/features/roomlist/impl/src/main/res/values-sk/translations.xml index 250822f4c9..0a1879a484 100644 --- a/features/roomlist/impl/src/main/res/values-sk/translations.xml +++ b/features/roomlist/impl/src/main/res/values-sk/translations.xml @@ -1,6 +1,8 @@"Создайте новую беседу или комнату" +"Начните переписку с отправки сообщения." +"Пока нет доступных чатов." +"Все чаты" +"Похоже, вы используете новое устройство. Чтобы получить доступ к зашифрованным сообщениям в дальнейшем, проверьте их на другом устройстве." +"Подтвердите, что это вы" +"Vytvorte novú konverzáciu alebo miestnosť" +"Začnite tým, že niekomu pošlete správu." +"Zatiaľ žiadne konverzácie." "Všetky konverzácie" "Vyzerá to tak, že používate nové zariadenie. Overte svoj prístup k zašifrovaným správam pomocou vášho druhého zariadenia." "Overte, že ste to vy" diff --git a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..b4d22c5b26 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,4 @@ + ++ diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml index 3a1c3cbad6..d63f96d07c 100644 --- a/features/roomlist/impl/src/main/res/values/localazy.xml +++ b/features/roomlist/impl/src/main/res/values/localazy.xml @@ -1,6 +1,8 @@"建立新的對話或聊天室" +"Create a new conversation or room" +"Get started by messaging someone." +"No chats yet." "All Chats" "Looks like you’re using a new device. Verify with another device to access your encrypted messages moving forwards." "Verify it’s you" diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index ce8e83c70d..eaa3801e12 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -47,9 +47,10 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope @@ -110,21 +111,20 @@ class RoomListPresenterTests { @Test fun `present - load 1 room with success`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() + val roomListService = FakeRoomListService() val matrixClient = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource + roomListService = roomListService ) val presenter = createRoomListPresenter(matrixClient) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val initialState = awaitItem() + val initialState = consumeItemsUntilPredicate { state -> state.roomList.size == 16 }.last() // Room list is loaded with 16 placeholders Truth.assertThat(initialState.roomList.size).isEqualTo(16) Truth.assertThat(initialState.roomList.all { it.isPlaceholder }).isTrue() - roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled())) - val withRoomState = awaitItem() + roomListService.postAllRooms(listOf(aRoomSummaryFilled())) + val withRoomState = consumeItemsUntilPredicate { state -> state.roomList.size == 1 }.last() Truth.assertThat(withRoomState.roomList.size).isEqualTo(1) Truth.assertThat(withRoomState.roomList.first()) .isEqualTo(aRoomListRoomSummary) @@ -133,68 +133,66 @@ class RoomListPresenterTests { @Test fun `present - load 1 room with success and filter rooms`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() + val roomListService = FakeRoomListService() val matrixClient = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource + roomListService = roomListService ) val presenter = createRoomListPresenter(matrixClient) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled())) - skipItems(1) - val loadedState = awaitItem() + roomListService.postAllRooms(listOf(aRoomSummaryFilled())) + val loadedState = consumeItemsUntilPredicate { state -> state.roomList.size == 1 }.last() // Test filtering with result loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3))) - skipItems(1) // Filter update - val withNotFilteredRoomState = awaitItem() - Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3)) - Truth.assertThat(withNotFilteredRoomState.filteredRoomList.size).isEqualTo(1) - Truth.assertThat(withNotFilteredRoomState.filteredRoomList.first()) + val withFilteredRoomState = consumeItemsUntilPredicate { state -> state.filteredRoomList.size == 1 }.last() + Truth.assertThat(withFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3)) + Truth.assertThat(withFilteredRoomState.filteredRoomList.size).isEqualTo(1) + Truth.assertThat(withFilteredRoomState.filteredRoomList.first()) .isEqualTo(aRoomListRoomSummary) // Test filtering without result - withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada")) - skipItems(1) // Filter update - Truth.assertThat(awaitItem().filter).isEqualTo("tada") - Truth.assertThat(awaitItem().filteredRoomList).isEmpty() + withFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada")) + val withNotFilteredRoomState = consumeItemsUntilPredicate { state -> state.filteredRoomList.size == 0 }.last() + Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo("tada") + Truth.assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty() } } @Test fun `present - update visible range`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() + val roomListService = FakeRoomListService() val matrixClient = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource + roomListService = roomListService ) val presenter = createRoomListPresenter(matrixClient) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - roomSummaryDataSource.postAllRooms(listOf(aRoomSummaryFilled())) + roomListService.postAllRooms(listOf(aRoomSummaryFilled())) val loadedState = awaitItem() // check initial value - Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull() + Truth.assertThat(roomListService.latestSlidingSyncRange).isNull() // Test empty range loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(1, 0))) - Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull() + Truth.assertThat(roomListService.latestSlidingSyncRange).isNull() // Update visible range and check that range is transmitted to the SDK after computation loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 0))) - Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + Truth.assertThat(roomListService.latestSlidingSyncRange) .isEqualTo(IntRange(0, 20)) loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 1))) - Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + Truth.assertThat(roomListService.latestSlidingSyncRange) .isEqualTo(IntRange(0, 21)) loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(19, 29))) - Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + Truth.assertThat(roomListService.latestSlidingSyncRange) .isEqualTo(IntRange(0, 49)) loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(49, 59))) - Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + Truth.assertThat(roomListService.latestSlidingSyncRange) .isEqualTo(IntRange(29, 79)) loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 159))) - Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + Truth.assertThat(roomListService.latestSlidingSyncRange) .isEqualTo(IntRange(129, 179)) loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 259))) - Truth.assertThat(roomSummaryDataSource.latestSlidingSyncRange) + Truth.assertThat(roomListService.latestSlidingSyncRange) .isEqualTo(IntRange(129, 279)) cancelAndIgnoreRemainingEvents() } @@ -202,9 +200,9 @@ class RoomListPresenterTests { @Test fun `present - handle DismissRequestVerificationPrompt`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() + val roomListService = FakeRoomListService() val matrixClient = FakeMatrixClient( - roomSummaryDataSource = roomSummaryDataSource + roomListService = roomListService ) val presenter = createRoomListPresenter( client = matrixClient, @@ -319,7 +317,7 @@ class RoomListPresenterTests { inviteStateDataSource = inviteStateDataSource, leaveRoomPresenter = leaveRoomPresenter, roomListDataSource = RoomListDataSource( - client.roomSummaryDataSource, + client.roomListService, lastMessageTimestampFormatter, roomLastMessageFormatter, coroutineDispatchers = testCoroutineDispatchers() diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt index 88e69068fd..ce7bf685a8 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/datasource/DefaultInviteStateDataSourceTest.kt @@ -25,8 +25,8 @@ import io.element.android.features.roomlist.impl.InvitesState import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.runTest import org.junit.Test @@ -35,8 +35,8 @@ internal class DefaultInviteStateDataSourceTest { @Test fun `emits NoInvites state if invites list is empty`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() - val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val roomListService = FakeRoomListService() + val client = FakeMatrixClient(roomListService = roomListService) val seenStore = FakeSeenInvitesStore() val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers()) @@ -49,9 +49,9 @@ internal class DefaultInviteStateDataSourceTest { @Test fun `emits NewInvites state if unseen invite exists`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) - val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val roomListService = FakeRoomListService() + roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) + val client = FakeMatrixClient(roomListService = roomListService) val seenStore = FakeSeenInvitesStore() val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers()) @@ -65,9 +65,9 @@ internal class DefaultInviteStateDataSourceTest { @Test fun `emits NewInvites state if multiple invites exist and at least one is unseen`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2))) - val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val roomListService = FakeRoomListService() + roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2))) + val client = FakeMatrixClient(roomListService = roomListService) val seenStore = FakeSeenInvitesStore() seenStore.publishRoomIds(setOf(A_ROOM_ID)) val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true)) @@ -82,9 +82,9 @@ internal class DefaultInviteStateDataSourceTest { @Test fun `emits SeenInvites state if invite exists in seen store`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) - val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val roomListService = FakeRoomListService() + roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) + val client = FakeMatrixClient(roomListService = roomListService) val seenStore = FakeSeenInvitesStore() seenStore.publishRoomIds(setOf(A_ROOM_ID)) val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true)) @@ -100,8 +100,8 @@ internal class DefaultInviteStateDataSourceTest { @Test fun `emits new state in response to upstream events`() = runTest { - val roomSummaryDataSource = FakeRoomSummaryDataSource() - val client = FakeMatrixClient(roomSummaryDataSource = roomSummaryDataSource) + val roomListService = FakeRoomListService() + val client = FakeMatrixClient(roomListService = roomListService) val seenStore = FakeSeenInvitesStore() val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers()) @@ -112,7 +112,7 @@ internal class DefaultInviteStateDataSourceTest { Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites) // When a single invite is received, state should be NewInvites - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) + roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID))) skipItems(1) Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites) @@ -122,12 +122,12 @@ internal class DefaultInviteStateDataSourceTest { Truth.assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites) // Another new invite resets it to NewInvites - roomSummaryDataSource.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2))) + roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2))) skipItems(1) Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites) // All of the invites going away reverts to NoInvites - roomSummaryDataSource.postInviteRooms(emptyList()) + roomListService.postInviteRooms(emptyList()) skipItems(1) Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites) } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt index 77984a3bb2..0ffd518669 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -44,15 +43,16 @@ import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage -import io.element.android.libraries.designsystem.components.button.ButtonWithProgress import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.aliasButtonText +import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.matrix.api.verification.VerificationEmoji import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.sync.Mutex import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep @Composable @@ -75,6 +75,7 @@ fun VerifySelfSessionView( val buttonsVisible by remember(verificationFlowStep) { derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed } } + Mutex() HeaderFooterPage( modifier = modifier, header = { @@ -219,35 +220,33 @@ internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) ButtonColumnMolecule( modifier = Modifier.padding(bottom = 20.dp) ) { - ButtonWithProgress( - text = positiveButtonTitle?.let { stringResource(it) }, - showProgress = isVerifying, - modifier = Modifier.fillMaxWidth(), - onClick = { positiveButtonEvent?.let { eventSink(it) } } - ) + if (positiveButtonTitle != null) { + Button( + text = stringResource(positiveButtonTitle), + showProgress = isVerifying, + modifier = Modifier.fillMaxWidth(), + onClick = { positiveButtonEvent?.let { eventSink(it) } } + ) + } if (negativeButtonTitle != null) { TextButton( + text = stringResource(negativeButtonTitle), modifier = Modifier.fillMaxWidth(), onClick = negativeButtonCallback, enabled = negativeButtonEnabled, - ) { - Text( - text = stringResource(negativeButtonTitle), - style = ElementTheme.typography.aliasButtonText, - ) - } + ) } } } @Preview @Composable -fun VerifySelfSessionViewLightPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = +internal fun VerifySelfSessionViewLightPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun VerifySelfSessionViewDarkPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = +internal fun VerifySelfSessionViewDarkPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/features/verifysession/impl/src/main/res/values-ru/translations.xml b/features/verifysession/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..552204fd7a --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,19 @@ + ++ diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..fc59911a93 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,9 @@ + +"Кажется, что-то не так. Время ожидания запроса истекло, либо запрос был отклонен." +"Убедитесь, что приведенные ниже смайлики совпадают со смайликами, показанными во время другого сеанса." +"Сравните смайлики" +"Ваш новый сеанс подтвержден. У него есть доступ к вашим зашифрованным сообщениям, и другие пользователи увидят его как доверенное." +"Чтобы получить доступ к зашифрованной истории сообщений, докажите, что это вы." +"Открыть существующий сеанс" +"Повторить проверку" +"Я готов" +"Ожидание соответствия" +"Сравните уникальные смайлики, убедившись, что они расположены в том же порядке." +"Они не совпадают" +"Они совпадают" +"Для продолжения работы примите запрос на запуск процесса проверки в другом сеансе." +"Ожидание принятия запроса" +"Проверка отменена" +"Начать" ++ diff --git a/gradle.properties b/gradle.properties index e15ee7a033..01bd6b9597 100644 --- a/gradle.properties +++ b/gradle.properties @@ -56,3 +56,6 @@ android.experimental.enableTestFixtures=true # Create BuildConfig files as bytecode to avoid Java compilation phase android.enableBuildConfigAsBytecode=true + +# This should be removed after upgrading to AGP 8.1.0 +android.suppressUnsupportedCompileSdk=34 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 248537b00b..21ba973418 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,9 +4,9 @@ [versions] # Project android_gradle_plugin = "8.1.0" -kotlin = "1.8.22" -ksp = "1.8.22-1.0.11" -molecule = "1.1.0" +kotlin = "1.9.0" +ksp = "1.9.0-1.0.12" +molecule = "1.2.0" # AndroidX material = "1.9.0" @@ -14,16 +14,16 @@ core = "1.10.1" datastore = "1.0.0" constraintlayout = "2.1.4" constraintlayout_compose = "1.0.1" -recyclerview = "1.3.0" +recyclerview = "1.3.1" lifecycle = "2.6.1" activity = "1.7.2" startup = "1.1.1" -media3 = "1.1.0" +media3 = "1.1.1" browser = "1.5.0" # Compose compose_bom = "2023.06.01" -composecompiler = "1.4.8" +composecompiler = "1.5.0" # Coroutines coroutines = "1.7.2" @@ -45,11 +45,11 @@ dependencycheck = "8.3.1" dependencyanalysis = "1.20.0" stem = "2.3.0" sqldelight = "1.5.5" -telephoto = "0.4.0" +telephoto = "0.5.0" # DI dagger = "2.47" -anvil = "2.4.6" +anvil = "2.4.7-1-8" # Auto service autoservice = "1.1.1" @@ -65,7 +65,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3" kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:32.2.0" +google_firebase_bom = "com.google.firebase:firebase-bom:32.2.2" # AndroidX androidx_material = { module = "com.google.android.material:material", version.ref = "material" } @@ -92,6 +92,7 @@ androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = androidx_preference = "androidx.preference:preference:1.2.0" androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" } +androidx_compose_material3 = "androidx.compose.material3:material3:1.2.0-alpha05" # Coroutines coroutines_core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } @@ -123,7 +124,7 @@ test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.5.2" test_uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0" test_junitext = "androidx.test.ext:junit:1.1.5" -test_mockk = "io.mockk:mockk:1.13.5" +test_mockk = "io.mockk:mockk:1.13.7" test_barista = "com.adevinta.android:barista:4.3.0" test_hamcrest = "org.hamcrest:hamcrest:2.2" test_orchestrator = "androidx.test:orchestrator:1.4.2" @@ -145,7 +146,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.34" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.1.42" sqldelight-driver-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } @@ -156,14 +157,14 @@ otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.1.0" vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } -statemachine = "com.freeletics.flowredux:compose:1.1.0" +statemachine = "com.freeletics.flowredux:compose:1.2.0" maplibre = "org.maplibre.gl:android-sdk:10.2.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.0" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.0" # Analytics posthog = "com.posthog.android:posthog:2.0.3" -sentry = "io.sentry:sentry-android:6.26.0" +sentry = "io.sentry:sentry-android:6.28.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:42b2faa417c1e95f430bf8f6e379adba25ad5ef8" # Di @@ -196,7 +197,7 @@ kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } anvil = { id = "com.squareup.anvil", version.ref = "anvil" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } -ktlint = "org.jlleitschuh.gradle.ktlint:11.5.0" +ktlint = "org.jlleitschuh.gradle.ktlint:11.5.1" dependencygraph = { id = "com.savvasdalkitsis.module-dependency-graph", version.ref = "dependencygraph" } dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "dependencycheck" } dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyanalysis" } diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts index 92e3c46126..57e4ca3569 100644 --- a/libraries/androidutils/build.gradle.kts +++ b/libraries/androidutils/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(libs.timber) implementation(libs.androidx.corektx) implementation(libs.androidx.activity.activity) + implementation(libs.androidx.recyclerview) implementation(libs.androidx.exifinterface) implementation(libs.androidx.security.crypto) implementation(libs.androidx.browser) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/MatrixTimelineItemsDiffCallback.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DefaultDiffCallback.kt similarity index 70% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/MatrixTimelineItemsDiffCallback.kt rename to libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DefaultDiffCallback.kt index 4a78447bd7..219441d5e6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/diff/MatrixTimelineItemsDiffCallback.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DefaultDiffCallback.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,14 +14,17 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.timeline.diff +package io.element.android.libraries.androidutils.diff import androidx.recyclerview.widget.DiffUtil -import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem -internal class MatrixTimelineItemsDiffCallback( - private val oldList: List"我準備好了" +"等待比對" +"不相符" +"相符" +"驗證已取消" +"開始" +, - private val newList: List +/** + * Default implementation of [DiffUtil.Callback] that uses [areItemsTheSame] to compare items. + */ +internal class DefaultDiffCallback ( + private val oldList: List , + private val newList: List , + private val areItemsTheSame: (oldItem: T?, newItem: T?) -> Boolean, ) : DiffUtil.Callback() { override fun getOldListSize(): Int { @@ -35,11 +38,7 @@ internal class MatrixTimelineItemsDiffCallback( override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val oldItem = oldList.getOrNull(oldItemPosition) val newItem = newList.getOrNull(newItemPosition) - return if (oldItem is MatrixTimelineItem.Event && newItem is MatrixTimelineItem.Event) { - oldItem.uniqueId == newItem.uniqueId - } else { - false - } + return areItemsTheSame(oldItem, newItem) } override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCache.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCache.kt new file mode 100644 index 0000000000..3d1161e2e0 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCache.kt @@ -0,0 +1,67 @@ +/* + * 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.libraries.androidutils.diff + +/** + * A cache that can be used to store some data that can be invalidated when a diff is applied. + * The cache is invalidated by the [DiffCacheInvalidator]. + */ +interface DiffCache { + fun get(index: Int): E? + fun indices(): IntRange + fun isEmpty(): Boolean +} + +/** + * A [DiffCache] that can be mutated by adding, removing or updating elements. + */ +interface MutableDiffCache : DiffCache { + fun removeAt(index: Int): E? + fun add(index: Int, element: E?) + operator fun set(index: Int, element: E?) +} + +/** + * A [MutableDiffCache] backed by a [MutableList]. + * + */ +class MutableListDiffCache (private val mutableList: MutableList = ArrayList()) : MutableDiffCache { + + override fun removeAt(index: Int): E? { + return mutableList.removeAt(index) + } + + override fun get(index: Int): E? { + return mutableList.getOrNull(index) + } + + override fun indices(): IntRange { + return mutableList.indices + } + + override fun isEmpty(): Boolean { + return mutableList.isEmpty() + } + + override operator fun set(index: Int, element: E?) { + mutableList[index] = element + } + + override fun add(index: Int, element: E?) { + mutableList.add(index, element) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheInvalidator.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheInvalidator.kt new file mode 100644 index 0000000000..4ebdc3224f --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheInvalidator.kt @@ -0,0 +1,63 @@ +/* + * 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.libraries.androidutils.diff + +/** + * [DiffCacheInvalidator] is used to invalidate the cache when the list is updated. + * It is used by [DiffCacheUpdater]. + * Check the default implementation [DefaultDiffCacheInvalidator]. + */ +interface DiffCacheInvalidator { + fun onChanged(position: Int, count: Int, cache: MutableDiffCache ) + + fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache ) + + fun onInserted(position: Int, count: Int, cache: MutableDiffCache ) + + fun onRemoved(position: Int, count: Int, cache: MutableDiffCache ) +} + +/** + * Default implementation of [DiffCacheInvalidator]. + * It invalidates the cache by setting values to null. + */ +class DefaultDiffCacheInvalidator : DiffCacheInvalidator { + + override fun onChanged(position: Int, count: Int, cache: MutableDiffCache ) { + for (i in position until position + count) { + // Invalidate cache + cache[i] = null + } + } + + override fun onMoved(fromPosition: Int, toPosition: Int, cache: MutableDiffCache ) { + val model = cache.removeAt(fromPosition) + cache.add(toPosition, model) + } + + override fun onInserted(position: Int, count: Int, cache: MutableDiffCache ) { + repeat(count) { + cache.add(position, null) + } + } + + override fun onRemoved(position: Int, count: Int, cache: MutableDiffCache ) { + repeat(count) { + cache.removeAt(position) + } + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt new file mode 100644 index 0000000000..500edcb135 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt @@ -0,0 +1,70 @@ +/* + * 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.libraries.androidutils.diff + +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import timber.log.Timber +import kotlin.system.measureTimeMillis + +/** + * Class in charge of updating a [MutableDiffCache] according to the cache invalidation rules provided by the [DiffCacheInvalidator]. + * @param ListItem the type of the items in the list + * @param CachedItem the type of the items in the cache + * @param diffCache the cache to update + * @param detectMoves true if DiffUtil should try to detect moved items, false otherwise + * @param cacheInvalidator the invalidator to use to update the cache + * @param areItemsTheSame the function to use to compare items + */ +class DiffCacheUpdater ( + private val diffCache: MutableDiffCache , + private val detectMoves: Boolean = false, + private val cacheInvalidator: DiffCacheInvalidator = DefaultDiffCacheInvalidator(), + private val areItemsTheSame: (oldItem: ListItem?, newItem: ListItem?) -> Boolean, +) { + + private val lock = Object() + private var prevOriginalList: List = emptyList() + + private val listUpdateCallback = object : ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + cacheInvalidator.onInserted(position, count, diffCache) + } + + override fun onRemoved(position: Int, count: Int) { + cacheInvalidator.onRemoved(position, count, diffCache) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + cacheInvalidator.onMoved(fromPosition, toPosition, diffCache) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + cacheInvalidator.onChanged(position, count, diffCache) + } + } + + fun updateWith(newOriginalList: List ) = synchronized(lock) { + val timeToDiff = measureTimeMillis { + val diffCallback = DefaultDiffCallback(prevOriginalList, newOriginalList, areItemsTheSame) + val diffResult = DiffUtil.calculateDiff(diffCallback, detectMoves) + prevOriginalList = newOriginalList + diffResult.dispatchUpdatesTo(listUpdateCallback) + } + Timber.v("Time to apply diff on new list of ${newOriginalList.size} items: $timeToDiff ms") + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 8347990303..a9a17dcceb 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -183,7 +183,7 @@ fun Context.startInstallFromSourceIntent( noActivityFoundMessage: String = getString(R.string.error_no_compatible_app_found), ) { val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) - .setData(Uri.parse(String.format("package:%s", packageName))) + .setData(Uri.parse("package:$packageName")) try { activityResultLauncher.launch(intent) } catch (activityNotFoundException: ActivityNotFoundException) { diff --git a/libraries/androidutils/src/main/res/values-ru/translations.xml b/libraries/androidutils/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..bb236dc893 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ru/translations.xml @@ -0,0 +1,4 @@ + + + diff --git a/libraries/androidutils/src/main/res/values/integers.xml b/libraries/androidutils/src/main/res/values/integers.xml index ecbfa4cdda..2f9e641bdf 100644 --- a/libraries/androidutils/src/main/res/values/integers.xml +++ b/libraries/androidutils/src/main/res/values/integers.xml @@ -15,9 +15,9 @@ ~ limitations under the License. --> -"Не найдено совместимое приложение для обработки этого действия." ++ - diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeInputs.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeInputs.kt index b96d9e166b..534c9d741b 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeInputs.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeInputs.kt @@ -23,5 +23,5 @@ import com.bumble.appyx.core.plugin.plugins interface NodeInputs : Plugin inline fun1 -0 +1 +0 Node.inputs(): I { - return plugins().firstOrNull() ?: throw RuntimeException("Make sure to actually pass NodeInputs plugin to your node") + return requireNotNull(plugins().firstOrNull()) { "Make sure to actually pass NodeInputs plugin to your node" } } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/Try.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/Try.kt index b91d249547..fe801e71f7 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/Try.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/Try.kt @@ -16,11 +16,11 @@ package io.element.android.libraries.core.data -inline fun tryOrNull(noinline onError: ((Throwable) -> Unit)? = null, operation: () -> A): A? { +inline fun tryOrNull(onError: ((Throwable) -> Unit) = { }, operation: () -> A): A? { return try { operation() } catch (any: Throwable) { - onError?.invoke(any) + onError.invoke(any) null } } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index db07432df0..343f5ce351 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -72,7 +72,3 @@ fun String.ellipsize(length: Int): String { return "${this.take(length)}…" } - -inline fun Any?.takeAs(): R? { - return takeIf { it is R } as R? -} diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt index 483e27af71..5aefcdcd7b 100644 --- a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageTimestampFormatterTest.kt @@ -101,7 +101,7 @@ class DefaultLastMessageTimestampFormatterTest { * Create DefaultLastMessageFormatter and set current time to the provided date. */ private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageTimestampFormatter { - val clock = FakeClock().also { it.givenInstant(Instant.parse(currentDate)) } + val clock = FakeClock().apply { givenInstant(Instant.parse(currentDate)) } val localDateTimeProvider = LocalDateTimeProvider(clock, TimeZone.UTC) val dateFormatters = DateFormatters(Locale.US, clock, TimeZone.UTC) return DefaultLastMessageTimestampFormatter(localDateTimeProvider, dateFormatters) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt index 0e33567129..47b951baa2 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/VectorIcons.kt @@ -25,4 +25,6 @@ object VectorIcons { val DoorOpen = R.drawable.ic_door_open_24 val DeveloperMode = R.drawable.ic_developer_mode val ReportContent = R.drawable.ic_report_content + val Groups = R.drawable.ic_groups + val Share = R.drawable.ic_share } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt index 8f96ed6b6e..dcd1ea11bc 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt @@ -16,7 +16,6 @@ package io.element.android.libraries.designsystem.atomic.atoms -import android.graphics.BlurMaskFilter import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -27,24 +26,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.RoundRect -import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.clipPath -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.R +import io.element.android.libraries.designsystem.modifiers.blurCompat +import io.element.android.libraries.designsystem.modifiers.blurredShapeShadow +import io.element.android.libraries.designsystem.modifiers.canUseBlurMaskFilter import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.theme.ElementTheme @@ -53,6 +44,7 @@ import io.element.android.libraries.theme.ElementTheme fun ElementLogoAtom( size: ElementLogoAtomSize, modifier: Modifier = Modifier, + useBlurredShadow: Boolean = canUseBlurMaskFilter(), darkTheme: Boolean = isSystemInDarkTheme(), ) { val blur = if (darkTheme) 160.dp else 24.dp @@ -66,22 +58,35 @@ fun ElementLogoAtom( .border(size.borderWidth, borderColor, RoundedCornerShape(size.cornerRadius)), contentAlignment = Alignment.Center, ) { - Box( - Modifier - .size(size.outerSize) - .shapeShadow( - color = shadowColor, - cornerRadius = size.cornerRadius, - blurRadius = size.shadowRadius, - offsetY = 8.dp, - ) - ) + if (useBlurredShadow) { + Box( + Modifier + .size(size.outerSize) + .blurredShapeShadow( + color = shadowColor, + cornerRadius = size.cornerRadius, + blurRadius = size.shadowRadius, + offsetY = 8.dp, + ) + ) + } else { + Box( + Modifier + .size(size.outerSize) + .shadow( + elevation = size.shadowRadius, + shape = RoundedCornerShape(size.cornerRadius), + clip = false, + ambientColor = shadowColor + ) + ) + } Box( Modifier .clip(RoundedCornerShape(size.cornerRadius)) .size(size.outerSize) .background(backgroundColor) - .blur(blur) + .blurCompat(blur) ) Image( modifier = Modifier.size(size.logoSize), @@ -121,44 +126,6 @@ sealed class ElementLogoAtomSize( ) } -fun Modifier.shapeShadow( - color: Color = Color.Black, - cornerRadius: Dp = 0.dp, - offsetX: Dp = 0.dp, - offsetY: Dp = 0.dp, - blurRadius: Dp = 0.dp, -) = then( - drawBehind { - drawIntoCanvas { canvas -> - val path = Path().apply { - addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx()))) - } - - clipPath(path, ClipOp.Difference) { - val paint = Paint() - val frameworkPaint = paint.asFrameworkPaint() - if (blurRadius != 0.dp) { - frameworkPaint.maskFilter = BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL) - } - frameworkPaint.color = color.toArgb() - - val leftPixel = offsetX.toPx() - val topPixel = offsetY.toPx() - val rightPixel = size.width + topPixel - val bottomPixel = size.height + leftPixel - - canvas.drawRect( - left = leftPixel, - top = topPixel, - right = rightPixel, - bottom = bottomPixel, - paint = paint, - ) - } - } - } -) - @Composable @DayNightPreviews internal fun ElementLogoAtomMediumPreview() { @@ -172,7 +139,19 @@ internal fun ElementLogoAtomLargePreview() { } @Composable -private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize) { +@DayNightPreviews +internal fun ElementLogoAtomMediumNoBlurShadowPreview() { + ContentToPreview(ElementLogoAtomSize.Medium, useBlurredShadow = false) +} + +@Composable +@DayNightPreviews +internal fun ElementLogoAtomLargeNoBlurShadowPreview() { + ContentToPreview(ElementLogoAtomSize.Large, useBlurredShadow = false) +} + +@Composable +private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize, useBlurredShadow: Boolean = true) { ElementPreview { Box( Modifier @@ -180,7 +159,7 @@ private fun ContentToPreview(elementLogoAtomSize: ElementLogoAtomSize) { .background(ElementTheme.colors.bgSubtlePrimary), contentAlignment = Alignment.Center ) { - ElementLogoAtom(elementLogoAtomSize) + ElementLogoAtom(elementLogoAtomSize, useBlurredShadow = useBlurredShadow) } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt index 6b20c96880..2af9e77b99 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt @@ -70,7 +70,7 @@ fun InfoListItemMolecule( @DayNightPreviews @Composable -fun InfoListItemMoleculePreview() { +internal fun InfoListItemMoleculePreview() { ElementPreview { val color = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray Column( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonColumnMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonColumnMolecule.kt index a8ad97a950..9fc688f227 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonColumnMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonColumnMolecule.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.TextButton @Composable @@ -59,11 +59,8 @@ internal fun ButtonColumnMoleculeDarkPreview() = @Composable private fun ContentToPreview() { ButtonColumnMolecule { - Button(onClick = {}, modifier = Modifier.fillMaxWidth()) { - Text(text = "Button") - } - TextButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { - Text(text = "TextButton") - } + Button(text = "Button", onClick = {}, modifier = Modifier.fillMaxWidth()) + OutlinedButton(text = "OutlinedButton", onClick = {}, modifier = Modifier.fillMaxWidth()) + TextButton(text = "TextButton", onClick = {}, modifier = Modifier.fillMaxWidth()) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt index b8b5a2146f..7c03a2106e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/ButtonRowMolecule.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton @Composable @@ -54,11 +53,7 @@ internal fun ButtonRowMoleculeDarkPreview() = @Composable private fun ContentToPreview() { ButtonRowMolecule { - TextButton(onClick = { }) { - Text("Button 1") - } - TextButton(onClick = { }) { - Text("Button 2") - } + TextButton(text = "Button 1", onClick = {}) + TextButton(text = "Button 2", onClick = {}) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.kt new file mode 100644 index 0000000000..1a10aa2886 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitlePlaceholdersRowMolecule.kt @@ -0,0 +1,70 @@ +/* + * 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.libraries.designsystem.atomic.molecules + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.theme.placeholderBackground +import io.element.android.libraries.theme.ElementTheme + +@Composable +fun IconTitlePlaceholdersRowMolecule( + iconSize: Dp, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, +) { + Row( + modifier = modifier, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + ) { + Box( + modifier = Modifier + .size(iconSize) + .align(Alignment.CenterVertically) + .background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape) + ) + Spacer(modifier = Modifier.width(8.dp)) + PlaceholderAtom(width = 20.dp, height = 7.dp) + Spacer(modifier = Modifier.width(7.dp)) + PlaceholderAtom(width = 45.dp, height = 7.dp) + } +} + +@DayNightPreviews +@Composable +internal fun IconTitlePlaceholdersRowMoleculePreview() = ElementPreview { + IconTitlePlaceholdersRowMolecule( + iconSize = AvatarSize.TimelineRoom.dp, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt index eb9749498a..21bddd67a1 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt @@ -109,7 +109,7 @@ private fun ContentToPreview() { linkAnnotationTag = "", onClick = {}, onLongClick = {}, - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt index cc1d92ccd8..4856d54bff 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LabelledTextField.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -39,6 +40,7 @@ fun LabelledTextField( placeholder: String? = null, singleLine: Boolean = false, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, onValueChange: (String) -> Unit = {}, ) { Column( @@ -59,17 +61,18 @@ fun LabelledTextField( onValueChange = onValueChange, singleLine = singleLine, maxLines = maxLines, + keyboardOptions = keyboardOptions, ) } } @Preview @Composable -fun LabelledTextFieldLightPreview() = ElementPreviewLight { ContentToPreview() } +internal fun LabelledTextFieldLightPreview() = ElementPreviewLight { ContentToPreview() } @Preview @Composable -fun LabelledTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() } +internal fun LabelledTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() } @Composable private fun ContentToPreview() { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt index c6af3e1cdf..93a0c5d436 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt @@ -51,6 +51,6 @@ fun PinIcon( @DayNightPreviews @Composable -fun PinIconPreview() = ElementPreview { +internal fun PinIconPreview() = ElementPreview { PinIcon() } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt index a5ad996ea7..20589c89ee 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import io.element.android.libraries.designsystem.components.dialogs.DialogPreview +import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator @@ -129,9 +129,10 @@ private fun ProgressDialogContent( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomEnd ) { - TextButton(onClick = onCancelClicked) { - Text(stringResource(id = CommonStrings.action_cancel)) - } + TextButton( + text = stringResource(id = CommonStrings.action_cancel), + onClick = onCancelClicked, + ) } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt index b5aa9b85d3..d30b78ca7f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncFailure.kt @@ -48,9 +48,10 @@ fun AsyncFailure( Text(text = throwable.message ?: stringResource(id = CommonStrings.error_unknown)) if (onRetry != null) { Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = onRetry) { - Text(text = stringResource(id = CommonStrings.action_retry)) - } + Button( + text = stringResource(id = CommonStrings.action_retry), + onClick = onRetry + ) } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt index b28e52a5ff..2e2d98ddbb 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.layout.ContentScale 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 coil.compose.AsyncImage import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup @@ -91,10 +92,14 @@ private fun InitialsAvatar( Box( modifier.background(color = avatarColor), ) { + val fontSize = avatarData.size.dp.toSp() / 2 + val originalFont = ElementTheme.typography.fontBodyMdRegular + val ratio = fontSize.value / originalFont.fontSize.value + val lineHeight = originalFont.lineHeight * ratio Text( modifier = Modifier.align(Alignment.Center), text = avatarData.initial, - style = ElementTheme.typography.fontBodyMdRegular.copy(fontSize = avatarData.size.dp.toSp() / 2), + style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp), color = Color.White, ) } @@ -102,7 +107,7 @@ private fun InitialsAvatar( @Preview(group = PreviewGroup.Avatars) @Composable -fun AvatarPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) = +internal fun AvatarPreview(@PreviewParameter(AvatarDataProvider::class) avatarData: AvatarData) = ElementThemedPreview { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt index 1727fffd1c..14c7ad3eff 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt @@ -20,19 +20,16 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider open class AvatarDataProvider : PreviewParameterProvider { override val values: Sequence - get() { - AvatarSize.values() - .also { it.sortBy { item -> item.name } } - .asSequence() - return AvatarSize.values().asSequence().map { + get() = AvatarSize.values() + .asSequence() + .map { sequenceOf( anAvatarData(size = it), anAvatarData(size = it).copy(name = null), anAvatarData(size = it).copy(url = "aUrl"), ) } - .flatten() - } + .flatten() } fun anAvatarData( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonVisuals.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonVisuals.kt new file mode 100644 index 0000000000..24f3989f66 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonVisuals.kt @@ -0,0 +1,53 @@ +/* + * 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.libraries.designsystem.components.button + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.TextButton + +/** + * A sealed class that represents the different visual styles that a button can have. + */ +sealed interface ButtonVisuals { + + val action: () -> Unit + + /** + * Creates a [Button] composable based on the visual state. + */ + @Composable + fun Composable() + + data class Text(val text: String, override val action: () -> Unit) : ButtonVisuals { + @Composable + override fun Composable() { + TextButton(text = text, onClick = action) + } + } + data class Icon(val iconSource: IconSource, override val action: () -> Unit) : ButtonVisuals { + @Composable + override fun Composable() { + IconButton(onClick = action) { + Icon(iconSource.getPainter(), iconSource.contentDescription) + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonWithProgress.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonWithProgress.kt deleted file mode 100644 index 06702de253..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/ButtonWithProgress.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * 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.libraries.designsystem.components.button - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.progressSemantics -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonElevation -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.element.android.libraries.designsystem.preview.ElementThemedPreview -import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.aliasButtonText -import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.designsystem.theme.components.ElementButtonDefaults -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.theme.ElementTheme - -/** - * A component that will display a button with an indeterminate circular progressbar. - * When [showProgress] is true: - * - A circular progressbar is displayed. - * - [text] is replaced by [progressText], if defined. - * - [onClick] gets disabled. - */ -@Composable -fun ButtonWithProgress( - text: String?, - onClick: () -> Unit, - modifier: Modifier = Modifier, - showProgress: Boolean = false, - progressText: String? = text, - enabled: Boolean = true, - shape: Shape = ElementButtonDefaults.shape, - colors: ButtonColors = ElementButtonDefaults.buttonColors(), - elevation: ButtonElevation? = ElementButtonDefaults.buttonElevation(), - border: BorderStroke? = null, - contentPadding: PaddingValues = ElementButtonDefaults.ContentPadding, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - Button( - onClick = { - if (!showProgress) { - onClick() - } - }, - modifier = modifier, - enabled = enabled, - shape = shape, - colors = colors, - elevation = elevation, - border = border, - contentPadding = contentPadding, - interactionSource = interactionSource, - ) { - if (showProgress) { - CircularProgressIndicator( - modifier = Modifier - .progressSemantics() - .size(18.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - if (progressText != null) { - Spacer(Modifier.width(10.dp)) - Text(progressText, style = ElementTheme.typography.aliasButtonText) - } - } else if (text != null) { - Text(text, style = ElementTheme.typography.aliasButtonText) - } - } -} - -@Preview(group = PreviewGroup.Buttons) -@Composable -internal fun ButtonWithProgressPreview() = ElementThemedPreview { - ButtonWithProgress( - text = "Button with progress", - onClick = {}, - showProgress = true, - ) -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt index c97c1cc59e..76c256d967 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/button/MainActionButton.kt @@ -30,6 +30,7 @@ import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -51,7 +52,7 @@ fun MainActionButton( contentDescription: String = title, ) { val ripple = rememberRipple(bounded = false) - val interactionSource = MutableInteractionSource() + val interactionSource = remember { MutableInteractionSource() } Column( modifier.clickable( enabled = enabled, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt index b11c9f878d..0c2fca84d5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -17,20 +17,15 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource 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.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.utils.BooleanProvider +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings @OptIn(ExperimentalMaterial3Api::class) @@ -44,16 +39,8 @@ fun ConfirmationDialog( submitText: String = stringResource(id = CommonStrings.action_ok), cancelText: String = stringResource(id = CommonStrings.action_cancel), thirdButtonText: String? = null, - emphasizeSubmitButton: Boolean = false, onCancelClicked: () -> Unit = onDismiss, onThirdButtonClicked: () -> Unit = {}, - shape: Shape = AlertDialogDefaults.shape, - containerColor: Color = AlertDialogDefaults.containerColor, - iconContentColor: Color = AlertDialogDefaults.iconContentColor, - // According to the design team, `primary` should be used here instead of the default `onSurface` - titleContentColor: Color = MaterialTheme.colorScheme.primary, - textContentColor: Color = AlertDialogDefaults.textContentColor, - tonalElevation: Dp = AlertDialogDefaults.TonalElevation, ) { AlertDialog(modifier = modifier, onDismissRequest = onDismiss) { ConfirmationDialogContent( @@ -65,13 +52,6 @@ fun ConfirmationDialog( onSubmitClicked = onSubmitClicked, onCancelClicked = onCancelClicked, onThirdButtonClicked = onThirdButtonClicked, - shape = shape, - containerColor = containerColor, - iconContentColor = iconContentColor, - titleContentColor = titleContentColor, - textContentColor = textContentColor, - tonalElevation = tonalElevation, - emphasizeSubmitButton = emphasizeSubmitButton, ) } } @@ -87,13 +67,6 @@ private fun ConfirmationDialogContent( title: String? = null, thirdButtonText: String? = null, onThirdButtonClicked: () -> Unit = {}, - emphasizeSubmitButton: Boolean = false, - shape: Shape = AlertDialogDefaults.shape, - containerColor: Color = AlertDialogDefaults.containerColor, - iconContentColor: Color = AlertDialogDefaults.iconContentColor, - titleContentColor: Color = AlertDialogDefaults.titleContentColor, - textContentColor: Color = AlertDialogDefaults.textContentColor, - tonalElevation: Dp = AlertDialogDefaults.TonalElevation, icon: @Composable (() -> Unit)? = null, ) { SimpleAlertDialogContent( @@ -106,21 +79,14 @@ private fun ConfirmationDialogContent( onCancelClicked = onCancelClicked, thirdButtonText = thirdButtonText, onThirdButtonClicked = onThirdButtonClicked, - emphasizeSubmitButton = emphasizeSubmitButton, - shape = shape, - containerColor = containerColor, - iconContentColor = iconContentColor, - titleContentColor = titleContentColor, - textContentColor = textContentColor, - tonalElevation = tonalElevation, icon = icon, ) } @Preview(group = PreviewGroup.Dialogs) @Composable -internal fun ConfirmationDialogPreview(@PreviewParameter(BooleanProvider::class) emphasizeSubmitButton: Boolean) = - ElementThemedPreview { +internal fun ConfirmationDialogPreview() = + ElementThemedPreview(showBackground = false) { DialogPreview { ConfirmationDialogContent( content = "Content", @@ -130,7 +96,7 @@ internal fun ConfirmationDialogPreview(@PreviewParameter(BooleanProvider::class) thirdButtonText = "Disable", onSubmitClicked = {}, onCancelClicked = {}, - emphasizeSubmitButton = emphasizeSubmitButton, + onThirdButtonClicked = {}, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt index d69281b6e9..fb5c511bf4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -17,17 +17,15 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings @OptIn(ExperimentalMaterial3Api::class) @@ -38,12 +36,6 @@ fun ErrorDialog( title: String = ErrorDialogDefaults.title, submitText: String = ErrorDialogDefaults.submitText, onDismiss: () -> Unit = {}, - shape: Shape = AlertDialogDefaults.shape, - containerColor: Color = AlertDialogDefaults.containerColor, - iconContentColor: Color = AlertDialogDefaults.iconContentColor, - titleContentColor: Color = AlertDialogDefaults.titleContentColor, - textContentColor: Color = AlertDialogDefaults.textContentColor, - tonalElevation: Dp = AlertDialogDefaults.TonalElevation, ) { AlertDialog(modifier = modifier, onDismissRequest = onDismiss) { ErrorDialogContent( @@ -51,12 +43,6 @@ fun ErrorDialog( content = content, submitText = submitText, onSubmitText = onDismiss, - shape = shape, - containerColor = containerColor, - iconContentColor = iconContentColor, - titleContentColor = titleContentColor, - textContentColor = textContentColor, - tonalElevation = tonalElevation, ) } } @@ -68,12 +54,6 @@ private fun ErrorDialogContent( title: String = ErrorDialogDefaults.title, submitText: String = ErrorDialogDefaults.submitText, onSubmitText: () -> Unit = {}, - shape: Shape = AlertDialogDefaults.shape, - containerColor: Color = AlertDialogDefaults.containerColor, - iconContentColor: Color = AlertDialogDefaults.iconContentColor, - titleContentColor: Color = AlertDialogDefaults.titleContentColor, - textContentColor: Color = AlertDialogDefaults.textContentColor, - tonalElevation: Dp = AlertDialogDefaults.TonalElevation, ) { SimpleAlertDialogContent( modifier = modifier, @@ -81,12 +61,6 @@ private fun ErrorDialogContent( content = content, cancelText = submitText, onCancelClicked = onSubmitText, - shape = shape, - containerColor = containerColor, - iconContentColor = iconContentColor, - titleContentColor = titleContentColor, - textContentColor = textContentColor, - tonalElevation = tonalElevation, ) } @@ -98,7 +72,7 @@ object ErrorDialogDefaults { @Preview(group = PreviewGroup.Dialogs) @Composable internal fun ErrorDialogPreview() { - ElementThemedPreview { + ElementThemedPreview(showBackground = false) { DialogPreview { ErrorDialogContent( content = "Content", diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt index 5e22779085..85447b940b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt @@ -17,21 +17,18 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.TextButton +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.DialogPreview +import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings +@OptIn(ExperimentalMaterial3Api::class) @Composable fun RetryDialog( content: String, @@ -41,51 +38,17 @@ fun RetryDialog( dismissText: String = RetryDialogDefaults.dismissText, onRetry: () -> Unit = {}, onDismiss: () -> Unit = {}, - shape: Shape = AlertDialogDefaults.shape, - containerColor: Color = AlertDialogDefaults.containerColor, - iconContentColor: Color = AlertDialogDefaults.iconContentColor, - titleContentColor: Color = AlertDialogDefaults.titleContentColor, - textContentColor: Color = AlertDialogDefaults.textContentColor, - tonalElevation: Dp = AlertDialogDefaults.TonalElevation, ) { - AlertDialog( - modifier = modifier, - onDismissRequest = onDismiss, - title = { - Text( - text = title, - style = ElementTheme.typography.fontHeadingSmRegular, - ) - }, - text = { - Text( - text = content, - style = ElementTheme.typography.fontBodyMdRegular, - ) - }, - confirmButton = { - TextButton(onClick = onRetry) { - Text( - text = retryText, - style = ElementTheme.typography.fontBodyMdRegular, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - text = dismissText, - style = ElementTheme.typography.fontBodyMdRegular, - ) - } - }, - shape = shape, - containerColor = containerColor, - iconContentColor = iconContentColor, - titleContentColor = titleContentColor, - textContentColor = textContentColor, - tonalElevation = tonalElevation, - ) + AlertDialog(modifier = modifier, onDismissRequest = onDismiss) { + RetryDialogContent( + title = title, + content = content, + retryText = retryText, + dismissText = dismissText, + onRetry = onRetry, + onDismiss = onDismiss, + ) + } } @Composable @@ -97,12 +60,6 @@ private fun RetryDialogContent( dismissText: String = RetryDialogDefaults.dismissText, onRetry: () -> Unit = {}, onDismiss: () -> Unit = {}, - shape: Shape = AlertDialogDefaults.shape, - containerColor: Color = AlertDialogDefaults.containerColor, - iconContentColor: Color = AlertDialogDefaults.iconContentColor, - titleContentColor: Color = AlertDialogDefaults.titleContentColor, - textContentColor: Color = AlertDialogDefaults.textContentColor, - tonalElevation: Dp = AlertDialogDefaults.TonalElevation, ) { SimpleAlertDialogContent( modifier = modifier, @@ -112,12 +69,6 @@ private fun RetryDialogContent( onSubmitClicked = onRetry, cancelText = dismissText, onCancelClicked = onDismiss, - shape = shape, - containerColor = containerColor, - iconContentColor = iconContentColor, - titleContentColor = titleContentColor, - textContentColor = textContentColor, - tonalElevation = tonalElevation, ) } @@ -129,13 +80,12 @@ object RetryDialogDefaults { @Preview(group = PreviewGroup.Dialogs) @Composable -internal fun RetryDialogPreview() = ElementThemedPreview { ContentToPreview() } - -@Composable -private fun ContentToPreview() { - DialogPreview { - RetryDialogContent( - content = "Content", - ) +internal fun RetryDialogPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + RetryDialogContent( + content = "Content", + ) + } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt index 02c0e4eb05..50b5ab2fe4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt @@ -17,6 +17,8 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth @@ -35,6 +37,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.toEnabledColor +import io.element.android.libraries.designsystem.toSecondaryEnabledColor import io.element.android.libraries.theme.ElementTheme @Composable @@ -42,6 +45,7 @@ fun PreferenceCheckbox( title: String, isChecked: Boolean, modifier: Modifier = Modifier, + supportingText: String? = null, enabled: Boolean = true, icon: ImageVector? = null, showIconAreaIfNoIcon: Boolean = false, @@ -60,13 +64,23 @@ fun PreferenceCheckbox( enabled = enabled, isVisible = showIconAreaIfNoIcon ) - Text( - modifier = Modifier - .weight(1f), - style = ElementTheme.typography.fontBodyLgRegular, - text = title, - color = enabled.toEnabledColor(), - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + color = enabled.toEnabledColor(), + ) + if (supportingText != null) { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = supportingText, + color = enabled.toSecondaryEnabledColor(), + ) + } + } Checkbox( modifier = Modifier .align(Alignment.CenterVertically), @@ -83,10 +97,19 @@ internal fun PreferenceCheckboxPreview() = ElementThemedPreview { ContentToPrevi @Composable private fun ContentToPreview() { - PreferenceCheckbox( - title = "Checkbox", - icon = Icons.Default.Announcement, - enabled = true, - isChecked = true - ) + Column { + PreferenceCheckbox( + title = "Checkbox", + icon = Icons.Default.Announcement, + enabled = true, + isChecked = true + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + icon = Icons.Default.Announcement, + enabled = true, + isChecked = true + ) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt index 2e4b9e196b..348a12bdd9 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt @@ -21,14 +21,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.theme.ElementTheme @Composable fun PreferenceDivider( modifier: Modifier = Modifier, ) { - Divider( + HorizontalDivider( modifier = modifier, color = ElementTheme.colors.borderDisabled, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt index bbd3583688..b9a8d267f5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Announcement -import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,6 +36,7 @@ import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.Switch import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toSecondaryEnabledColor diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Blur.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Blur.kt new file mode 100644 index 0000000000..fb3eb86c96 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Blur.kt @@ -0,0 +1,103 @@ +/* + * 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.libraries.designsystem.modifiers + +import android.graphics.BlurMaskFilter +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * @return true if the blur modifier is supported on the current OS version. + * + * The docs say the `blur` modifier is only supported on Android 12+: + * https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).blur(androidx.compose.ui.unit.Dp,androidx.compose.ui.draw.BlurredEdgeTreatment) + * */ +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) +fun canUseBlur(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +@Composable +fun canUseBlurMaskFilter() = !LocalView.current.isHardwareAccelerated + +fun Modifier.blurredShapeShadow( + color: Color = Color.Black, + cornerRadius: Dp = 0.dp, + offsetX: Dp = 0.dp, + offsetY: Dp = 0.dp, + blurRadius: Dp = 0.dp, +) = then( + drawBehind { + drawIntoCanvas { canvas -> + val path = Path().apply { + addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx()))) + } + + // Draw the blurred shadow, then cut out the shape from it + clipPath(path, ClipOp.Difference) { + val paint = Paint() + val frameworkPaint = paint.asFrameworkPaint() + if (blurRadius != 0.dp) { + frameworkPaint.maskFilter = BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL) + } + frameworkPaint.color = color.toArgb() + + val leftPixel = offsetX.toPx() + val topPixel = offsetY.toPx() + val rightPixel = size.width + topPixel + val bottomPixel = size.height + leftPixel + + canvas.drawRect( + left = leftPixel, + top = topPixel, + right = rightPixel, + bottom = bottomPixel, + paint = paint, + ) + } + } + } +) + +fun Modifier.blurCompat( + radius: Dp, + edgeTreatment: BlurredEdgeTreatment = BlurredEdgeTreatment.Rectangle +): Modifier = composed { + when { + radius.value == 0f -> this + canUseBlur() -> blur(radius, edgeTreatment) + else -> this // Added in case we find a way to make this work on older devices + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/DayNightPreviews.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/DayNightPreviews.kt index 201d6f7151..b91e6a1024 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/DayNightPreviews.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/DayNightPreviews.kt @@ -42,6 +42,13 @@ const val DAY_MODE_NAME = "D" * * NB: Content should be wrapped into [ElementPreview] to apply proper theming. */ -@Preview(name = DAY_MODE_NAME) -@Preview(name = NIGHT_MODE_NAME, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview( + name = DAY_MODE_NAME, + fontScale = 1f, +) +@Preview( + name = NIGHT_MODE_NAME, + uiMode = Configuration.UI_MODE_NIGHT_YES, + fontScale = 1f, +) annotation class DayNightPreviews diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt index aaff9a49f9..b704d0a26e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt @@ -30,6 +30,7 @@ object PreviewGroup { const val Preferences = "Preferences" const val Progress = "Progress Indicators" const val Search = "Search views" + const val Snackbars = "Snackbars" const val Sliders = "Sliders" const val Text = "Text" const val TextFields = "TextFields" diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt index a83bc5708c..dec26f548f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt @@ -19,9 +19,13 @@ package io.element.android.libraries.designsystem.preview import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity @OptIn(ExperimentalMaterial3Api::class) -val sheetStateForPreview = SheetState( +@Composable +fun sheetStateForPreview() = SheetState( skipPartiallyExpanded = true, initialValue = SheetValue.Expanded, + density = LocalDensity.current, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt new file mode 100644 index 0000000000..6d3ecfc82b --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.designsystem.preview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density + +/** + * Showkase does not take into account the `fontScale` parameter of the Preview annotation, so alter the + * LocalDensity in the CompositionLocalProvider. + */ +@Composable +fun WithFontScale(fontScale: Float, content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalDensity provides Density( + density = LocalDensity.current.density, + fontScale = fontScale + ) + ) { + content() + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt index a5fc895e5d..58c9a44709 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/ruler/WithRulers.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.OutlinedButton -import io.element.android.libraries.designsystem.theme.components.Text /** * Debug tool to add a vertical and a horizontal ruler on top of the content. @@ -76,8 +76,10 @@ internal fun WithRulerDarkPreview() = @Composable private fun ContentToPreview() { WithRulers(xRulersOffset = 20.dp, yRulersOffset = 15.dp) { - OutlinedButton(onClick = {}) { - Text(text = "A Button with rulers on it!") - } + OutlinedButton( + text = "A Button with rulers on it!", + size = ButtonSize.Medium, + onClick = {}, + ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/DpScale.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/DpScale.kt new file mode 100644 index 0000000000..c6408b662e --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/DpScale.kt @@ -0,0 +1,126 @@ +/* + * 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.libraries.designsystem.text + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.WithFontScale +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.theme.ElementTheme + +/** + * Return the maximum value between the receiver value and the value with fontScale applied. + * So if fontScale is >= 1f, the same value is returned, and if fontScale is < 1f, so returned value + * will be smaller. + */ +@Composable +fun Dp.applyScaleDown(): Dp = with(LocalDensity.current) { + return this@applyScaleDown * fontScale.coerceAtMost(1f) +} + +/** + * Return the minimum value between the receiver value and the value with fontScale applied. + * So if fontScale is <= 1f, the same value is returned, and if fontScale is > 1f, so returned value + * will be bigger. + */ +@Composable +fun Dp.applyScaleUp(): Dp = with(LocalDensity.current) { + return this@applyScaleUp * fontScale.coerceAtLeast(1f) +} + +@Preview +@Composable +internal fun DpScalePreview_0_75f() = WithFontScale(0.75f) { + ElementPreviewLight { + val fontSizeInDp = 16.dp + Column( + modifier = Modifier.padding(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "Text with size of 16.sp", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.toSp()) + ) + Text( + text = "Text with the same size (applyScaleUp)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleUp().toSp()) + ) + Text( + text = "Text with a smaller size (applyScaleDown)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleDown().toSp()) + ) + } + } +} + +@Preview +@Composable +internal fun DpScalePreview_1_0f() = WithFontScale(1f) { + ElementPreviewLight { + val fontSizeInDp = 16.dp + Column( + modifier = Modifier.padding(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "Text with size of 16.sp", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.toSp()) + ) + Text( + text = "Text with the same size (applyScaleUp)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleUp().toSp()) + ) + Text( + text = "Text with the same size (applyScaleDown)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleDown().toSp()) + ) + } + } +} + +@Preview +@Composable +internal fun DpScalePreview_1_5f() = WithFontScale(1.5f) { + ElementPreviewLight { + val fontSizeInDp = 16.dp + Column( + modifier = Modifier.padding(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "Text with size of 16.sp", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.toSp()) + ) + Text( + text = "Text with a bigger size (applyScaleUp)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleUp().toSp()) + ) + Text( + text = "Text with the same size (applyScaleDown)", + style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSizeInDp.applyScaleDown().toSp()) + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialogContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt similarity index 64% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialogContent.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt index c1e8b0c055..a3c7274c45 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialogContent.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.designsystem.components.dialogs +package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -22,26 +22,32 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn -import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Notifications import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Surface -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.theme.ElementTheme import kotlin.math.max +// Figma designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=911%3A343492&mode=design&t=jeyd1bXKOOx8y10r-1 + @Composable internal fun SimpleAlertDialogContent( content: String, @@ -53,13 +59,6 @@ internal fun SimpleAlertDialogContent( onSubmitClicked: () -> Unit = {}, thirdButtonText: String? = null, onThirdButtonClicked: () -> Unit = {}, - emphasizeSubmitButton: Boolean = false, - shape: Shape = AlertDialogDefaults.shape, - containerColor: Color = AlertDialogDefaults.containerColor, - iconContentColor: Color = AlertDialogDefaults.iconContentColor, - titleContentColor: Color = AlertDialogDefaults.titleContentColor, - textContentColor: Color = AlertDialogDefaults.textContentColor, - tonalElevation: Dp = AlertDialogDefaults.TonalElevation, icon: @Composable (() -> Unit)? = null, ) { AlertDialogContent( @@ -71,54 +70,47 @@ internal fun SimpleAlertDialogContent( if (thirdButtonText != null) { // If there is a 3rd item it should be at the end of the dialog // Having this 3rd action is discouraged, see https://m3.material.io/components/dialogs/guidelines#e13b68f5-e367-4275-ad6f-c552ee8e358f - TextButton(onClick = onThirdButtonClicked) { - Text( - text = thirdButtonText, - style = ElementTheme.typography.fontBodyMdRegular, - ) - } - } - TextButton(onClick = onCancelClicked) { - Text( - text = cancelText, - style = ElementTheme.typography.fontBodyMdRegular, + TextButton( + text = thirdButtonText, + size = ButtonSize.Medium, + onClick = onThirdButtonClicked, ) } + TextButton( + text = cancelText, + size = ButtonSize.Medium, + onClick = onCancelClicked, + ) if (submitText != null) { - TextButton(onClick = onSubmitClicked) { - Text( - text = submitText, - style = if (emphasizeSubmitButton) { - ElementTheme.typography.fontBodyMdMedium - } else { - ElementTheme.typography.fontBodyMdRegular - } - ) - } + Button( + text = submitText, + size = ButtonSize.Medium, + onClick = onSubmitClicked, + ) } } }, modifier = modifier, - title = { - if (title != null) { + title = title?.let { titleText -> + @Composable { Text( - text = title, - style = ElementTheme.typography.fontHeadingSmRegular, + text = titleText, + style = ElementTheme.typography.fontHeadingSmMedium, ) } }, text = { Text( text = content, - style = ElementTheme.typography.fontBodyMdRegular, + style = ElementTheme.materialTypography.bodyMedium, ) }, - shape = shape, - containerColor = containerColor, - iconContentColor = iconContentColor, - titleContentColor = titleContentColor, - textContentColor = textContentColor, - tonalElevation = tonalElevation, + shape = DialogContentDefaults.shape, + containerColor = DialogContentDefaults.containerColor, + iconContentColor = DialogContentDefaults.iconContentColor, + titleContentColor = DialogContentDefaults.titleContentColor, + textContentColor = DialogContentDefaults.textContentColor, + tonalElevation = 0.dp, icon = icon, // Note that a button content color is provided here from the dialog's token, but in // most cases, TextButtons should be used for dismiss and confirm buttons. @@ -128,6 +120,9 @@ internal fun SimpleAlertDialogContent( ) } +/** + * Copy of M3's `AlertDialogContent` so we can use it for previews. + */ @Composable internal fun AlertDialogContent( buttons: @Composable () -> Unit, @@ -150,13 +145,13 @@ internal fun AlertDialogContent( tonalElevation = tonalElevation, ) { Column( - modifier = Modifier.padding(DialogPadding) + modifier = Modifier.padding(DialogContentDefaults.externalPadding) ) { icon?.let { CompositionLocalProvider(LocalContentColor provides iconContentColor) { Box( Modifier - .padding(IconPadding) + .padding(DialogContentDefaults.iconPadding) .align(Alignment.CenterHorizontally) ) { icon() @@ -170,7 +165,7 @@ internal fun AlertDialogContent( Box( // Align the title to the center when an icon is present. Modifier - .padding(TitlePadding) + .padding(DialogContentDefaults.titlePadding) .align( if (icon == null) { Alignment.Start @@ -192,7 +187,7 @@ internal fun AlertDialogContent( Box( Modifier .weight(weight = 1f, fill = false) - .padding(TextPadding) + .padding(DialogContentDefaults.textPadding) .align(Alignment.Start) ) { text() @@ -216,7 +211,7 @@ internal fun AlertDialogContent( * customization. */ @Composable -internal fun AlertDialogFlowRow( +private fun AlertDialogFlowRow( mainAxisSpacing: Dp, crossAxisSpacing: Dp, content: @Composable () -> Unit @@ -243,7 +238,8 @@ internal fun AlertDialogFlowRow( if (sequences.isNotEmpty()) { crossAxisSpace += crossAxisSpacing.roundToPx() } - sequences += currentSequence.toList() + // Ensures that confirming actions appear above dismissive actions. + sequences.add(0, currentSequence.toList()) crossAxisSizes += currentCrossAxisSize crossAxisPositions += crossAxisSpace @@ -287,12 +283,11 @@ internal fun AlertDialogFlowRow( placeables[j].width + if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0 } - val arrangement = Arrangement.Bottom - // TODO(soboleva): rtl support - // Handle vertical direction + val arrangement = Arrangement.End val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 } with(arrangement) { - arrange(mainAxisLayoutSize, childrenMainAxisSizes, mainAxisPositions) + arrange(mainAxisLayoutSize, childrenMainAxisSizes, + layoutDirection, mainAxisPositions) } placeables.forEachIndexed { j, placeable -> placeable.place( @@ -317,14 +312,87 @@ internal fun DialogPreview(content: @Composable () -> Unit) { } } -// Paddings for each of the dialog's parts. -private val DialogPadding = PaddingValues(all = 24.dp) -private val IconPadding = PaddingValues(bottom = 16.dp) -private val TitlePadding = PaddingValues(bottom = 16.dp) -private val TextPadding = PaddingValues(bottom = 24.dp) +internal object DialogContentDefaults { + val shape = RoundedCornerShape(12.dp) + val externalPadding = PaddingValues(all = 24.dp) + val titlePadding = PaddingValues(bottom = 16.dp) + val iconPadding = PaddingValues(bottom = 8.dp) + val textPadding = PaddingValues(bottom = 16.dp) + + val containerColor: Color + @Composable + @ReadOnlyComposable + get()= ElementTheme.colors.bgCanvasDefault + + val textContentColor: Color + @Composable + @ReadOnlyComposable + get()= ElementTheme.materialColors.onSurfaceVariant + + val titleContentColor: Color + @Composable + @ReadOnlyComposable + get()= ElementTheme.materialColors.onSurface + val iconContentColor: Color + @Composable + @ReadOnlyComposable + get()= ElementTheme.materialColors.primary +} + +// Paddings for each of the dialog's parts. Taken from M3 source code. internal val ButtonsMainAxisSpacing = 8.dp internal val ButtonsCrossAxisSpacing = 12.dp internal val DialogMinWidth = 280.dp internal val DialogMaxWidth = 560.dp + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with title, icon and ok button") +@Composable +@Suppress("MaxLineLength") +internal fun DialogWithTitleIconAndOkButtonPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + icon = { + Icon(imageVector = Icons.Default.Notifications, contentDescription = null) + }, + title = "Dialog Title", + content = "A dialog is a type of modal window that appears in front of app content to provide critical information, or prompt for a decision to be made. Learn more", + cancelText = "OK", + onCancelClicked = {}, + ) + } + } +} + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with title and ok button") +@Composable +@Suppress("MaxLineLength") +internal fun DialogWithTitleAndOkButtonPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + title = "Dialog Title", + content = "A dialog is a type of modal window that appears in front of app content to provide critical information, or prompt for a decision to be made. Learn more", + cancelText = "OK", + onCancelClicked = {}, + ) + } + } +} + +@Preview(group = PreviewGroup.Dialogs, name = "Dialog with only message and ok button") +@Composable +@Suppress("MaxLineLength") +internal fun DialogWithOnlyMessageAndOkButtonPreview() { + ElementThemedPreview(showBackground = false) { + DialogPreview { + SimpleAlertDialogContent( + content = "A dialog is a type of modal window that appears in front of app content to provide critical information, or prompt for a decision to be made. Learn more", + cancelText = "OK", + onCancelClicked = {}, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt index 64c79a3906..8c5d96c400 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt @@ -18,68 +18,367 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ButtonElevation +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme + +// Designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&mode=design&t=U03tOFZz5FSLVUMa-1 @Composable fun Button( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: ButtonSize = ButtonSize.Large, + showProgress: Boolean = false, + leadingIcon: IconSource? = null, +) = ButtonInternal( + text = text, + onClick = onClick, + style = ButtonStyle.Filled, + modifier = modifier, + enabled = enabled, + size = size, + showProgress = showProgress, + leadingIcon = leadingIcon +) + +@Composable +fun OutlinedButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: ButtonSize = ButtonSize.Large, + showProgress: Boolean = false, + leadingIcon: IconSource? = null, +) = ButtonInternal( + text = text, + onClick = onClick, + style = ButtonStyle.Outlined, + modifier = modifier, + enabled = enabled, + size = size, + showProgress = showProgress, + leadingIcon = leadingIcon +) + +@Composable +fun TextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: ButtonSize = ButtonSize.Large, + showProgress: Boolean = false, + leadingIcon: IconSource? = null, +) = ButtonInternal( + text = text, + onClick = onClick, + style = ButtonStyle.Text, + modifier = modifier, + enabled = enabled, + size = size, + showProgress = showProgress, + leadingIcon = leadingIcon +) + +@Composable +internal fun ButtonInternal( + text: String, onClick: () -> Unit, + style: ButtonStyle, modifier: Modifier = Modifier, + colors: ButtonColors = style.getColors(), enabled: Boolean = true, - shape: Shape = ElementButtonDefaults.shape, - colors: ButtonColors = ElementButtonDefaults.buttonColors(), - elevation: ButtonElevation? = ElementButtonDefaults.buttonElevation(), - border: BorderStroke? = null, - contentPadding: PaddingValues = ElementButtonDefaults.ContentPadding, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - content: @Composable RowScope.() -> Unit + size: ButtonSize = ButtonSize.Large, + showProgress: Boolean = false, + leadingIcon: IconSource? = null, ) { + val minHeight = when (size) { + ButtonSize.Medium -> 40.dp + ButtonSize.Large -> 48.dp + } + + val contentPadding = when (size) { + ButtonSize.Medium -> { + when (style) { + ButtonStyle.Text -> PaddingValues(horizontal = 12.dp, vertical = 10.dp) + else -> PaddingValues(horizontal = 16.dp, vertical = 10.dp) + } + } + ButtonSize.Large -> { + when (style) { + ButtonStyle.Text -> PaddingValues(horizontal = 16.dp, vertical = 13.dp) + else -> PaddingValues(horizontal = 24.dp, vertical = 13.dp) + } + } + } + + val shape = when (style) { + ButtonStyle.Filled, ButtonStyle.Outlined -> RoundedCornerShape(percent = 50) + ButtonStyle.Text -> RectangleShape + } + + val border = when (style) { + ButtonStyle.Filled, ButtonStyle.Text -> null + ButtonStyle.Outlined -> BorderStroke( + width = 1.dp, + color = ElementTheme.colors.borderInteractiveSecondary + ) + } + + val textStyle = when (size) { + ButtonSize.Medium -> MaterialTheme.typography.labelLarge + ButtonSize.Large -> ElementTheme.typography.fontBodyLgMedium + } + + val internalPadding = when { + style == ButtonStyle.Text -> if (leadingIcon != null) PaddingValues(start = 8.dp) else PaddingValues(0.dp) + else -> PaddingValues(horizontal = 8.dp) + } + androidx.compose.material3.Button( - onClick = onClick, - modifier = modifier, + onClick = { + if (!showProgress) { + onClick() + } + }, + modifier = modifier.heightIn(min = minHeight), enabled = enabled, shape = shape, colors = colors, - elevation = elevation, + elevation = null, border = border, contentPadding = contentPadding, - interactionSource = interactionSource, - content = content, - ) + interactionSource = remember { MutableInteractionSource() }, + ) { + when { + showProgress -> { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + color = LocalContentColor.current, + strokeWidth = 2.dp, + ) + } + leadingIcon != null -> { + androidx.compose.material.Icon( + painter = leadingIcon.getPainter(), + contentDescription = null, + tint = LocalContentColor.current, + modifier = Modifier.size(20.dp), + ) + } + else -> Unit + } + Text( + text = text, + style = textStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(internalPadding), + ) + } } -object ElementButtonDefaults { - val ContentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp) - val shape: Shape @Composable get() = ButtonDefaults.shape +sealed interface IconSource { + val contentDescription: String? + + data class Resource(val id: Int, override val contentDescription: String? = null) : IconSource + data class Vector(val vector: ImageVector, override val contentDescription: String? = null) : IconSource + @Composable - fun buttonElevation(): ButtonElevation = ButtonDefaults.buttonElevation() + fun getPainter(): Painter = when (this) { + is Resource -> painterResource(id) + is Vector -> rememberVectorPainter(image = vector) + } +} + +enum class ButtonSize { + Medium, Large +} + +internal enum class ButtonStyle { + Filled, Outlined, Text; @Composable - fun buttonColors(): ButtonColors = ButtonDefaults.buttonColors() + fun getColors(): ButtonColors = when (this) { + Filled -> ButtonDefaults.buttonColors( + containerColor = ElementTheme.materialColors.primary, + contentColor = ElementTheme.materialColors.onPrimary, + disabledContainerColor = ElementTheme.colors.bgActionPrimaryDisabled, + disabledContentColor = ElementTheme.colors.textOnSolidPrimary + ) + Outlined -> ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = ElementTheme.materialColors.primary, + disabledContainerColor = Color.Transparent, + disabledContentColor = ElementTheme.colors.textDisabled, + ) + Text -> ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = if (LocalContentColor.current.isSpecified) LocalContentColor.current else ElementTheme.materialColors.primary, + disabledContainerColor = Color.Transparent, + disabledContentColor = ElementTheme.colors.textDisabled, + ) + } +} +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun FilledButtonMediumPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Filled, + size = ButtonSize.Medium, + ) } @Preview(group = PreviewGroup.Buttons) @Composable -internal fun ButtonPreview() = ElementThemedPreview { - Column { - Button(onClick = {}, enabled = true) { - Text(text = "Click me! - Enabled") - } - Button(onClick = {}, enabled = false) { - Text(text = "Click me! - Disabled") +internal fun FilledButtonLargePreview() { + ButtonCombinationPreview( + style = ButtonStyle.Filled, + size = ButtonSize.Large, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun OutlinedButtonMediumPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Outlined, + size = ButtonSize.Medium, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun OutlinedButtonLargePreview() { + ButtonCombinationPreview( + style = ButtonStyle.Outlined, + size = ButtonSize.Large, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun TextButtonMediumPreview() { + ButtonCombinationPreview( + style = ButtonStyle.Text, + size = ButtonSize.Medium, + ) +} + +@Preview(group = PreviewGroup.Buttons) +@Composable +internal fun TextButtonLargePreview() { + ButtonCombinationPreview( + style = ButtonStyle.Text, + size = ButtonSize.Large, + ) +} + +@Composable +private fun ButtonCombinationPreview( + style: ButtonStyle, + size: ButtonSize, + modifier: Modifier = Modifier, +) { + ElementThemedPreview { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(16.dp) + .width(IntrinsicSize.Max), + ) { + // Normal + ButtonRowPreview( + modifier = Modifier.then(modifier), + style = style, + size = size, + ) + + // With icon + ButtonRowPreview( + modifier = Modifier.then(modifier), + leadingIcon = IconSource.Vector(Icons.Outlined.Share), + style = style, + size = size, + ) + + // With progress + ButtonRowPreview( + modifier = Modifier.then(modifier), + showProgress = true, + style = style, + size = size, + ) } } } + +@Composable +private fun ButtonRowPreview( + style: ButtonStyle, + size: ButtonSize, + modifier: Modifier = Modifier, + leadingIcon: IconSource? = null, + showProgress: Boolean = false, +) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)) { + ButtonInternal( + text = "A button", + showProgress = showProgress, + onClick = {}, + style = style, + size = size, + leadingIcon = leadingIcon, + modifier = Modifier.then(modifier), + ) + ButtonInternal( + text = "A button", + showProgress = showProgress, + enabled = false, + onClick = {}, + style = style, + size = size, + leadingIcon = leadingIcon, + modifier = Modifier.then(modifier), + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Checkbox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Checkbox.kt index b754b3d420..31c6cc3647 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Checkbox.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Checkbox.kt @@ -17,15 +17,25 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.material3.CheckboxColors import androidx.compose.material3.CheckboxDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme + +// Designs in https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&mode=design&t=qb99xBP5mwwCtGkN-1 @Composable fun Checkbox( @@ -33,12 +43,20 @@ fun Checkbox( onCheckedChange: ((Boolean) -> Unit)?, modifier: Modifier = Modifier, enabled: Boolean = true, - colors: CheckboxColors = CheckboxDefaults.colors(), + hasError: Boolean = false, + indeterminate: Boolean = false, + colors: CheckboxColors = if (hasError) compoundErrorCheckBoxColors() else compoundCheckBoxColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { - androidx.compose.material3.Checkbox( - checked = checked, - onCheckedChange = onCheckedChange, + var indeterminateState by remember { mutableStateOf(indeterminate) } + androidx.compose.material3.TriStateCheckbox( + state = if (!checked && indeterminateState) ToggleableState.Indeterminate else ToggleableState(checked), + onClick = onCheckedChange?.let { + { + indeterminateState = false + onCheckedChange(!checked) + } + }, modifier = modifier, enabled = enabled, colors = colors, @@ -46,6 +64,30 @@ fun Checkbox( ) } +@Composable +private fun compoundCheckBoxColors(): CheckboxColors { + return CheckboxDefaults.colors( + checkedColor = ElementTheme.materialColors.primary, + uncheckedColor = ElementTheme.colors.borderInteractivePrimary, + checkmarkColor = ElementTheme.materialColors.onPrimary, + disabledUncheckedColor = ElementTheme.colors.borderDisabled, + disabledCheckedColor = ElementTheme.colors.iconDisabled, + disabledIndeterminateColor = ElementTheme.colors.iconDisabled, + ) +} + +@Composable +private fun compoundErrorCheckBoxColors(): CheckboxColors { + return CheckboxDefaults.colors( + checkedColor = ElementTheme.materialColors.error, + uncheckedColor = ElementTheme.materialColors.error, + checkmarkColor = ElementTheme.materialColors.onPrimary, + disabledUncheckedColor = ElementTheme.colors.borderDisabled, + disabledCheckedColor = ElementTheme.colors.iconDisabled, + disabledIndeterminateColor = ElementTheme.colors.iconDisabled, + ) +} + @Preview(group = PreviewGroup.Toggles) @Composable internal fun CheckboxesPreview() = ElementThemedPreview(vertical = false) { ContentToPreview() } @@ -53,9 +95,33 @@ internal fun CheckboxesPreview() = ElementThemedPreview(vertical = false) { Cont @Composable private fun ContentToPreview() { Column { - Checkbox(onCheckedChange = {}, enabled = true, checked = true) - Checkbox(onCheckedChange = {}, enabled = true, checked = false) - Checkbox(onCheckedChange = {}, enabled = false, checked = true) - Checkbox(onCheckedChange = {}, enabled = false, checked = false) + // Unchecked + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(onCheckedChange = {}, enabled = true, checked = false) + Checkbox(onCheckedChange = {}, enabled = false, checked = false) + } + // Checked + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(onCheckedChange = {}, enabled = true, checked = true) + Checkbox(onCheckedChange = {}, enabled = false, checked = true) + } + // Indeterminate + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(onCheckedChange = {}, enabled = true, checked = false, indeterminate = true) + Checkbox(onCheckedChange = {}, enabled = false, checked = false, indeterminate = true) + } + // Error + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(hasError = true, onCheckedChange = {}, checked = false) + Checkbox(hasError = true, onCheckedChange = {}, enabled = false, checked = false) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(hasError = true, onCheckedChange = {}, enabled = true, checked = true) + Checkbox(hasError = true, onCheckedChange = {}, enabled = false, checked = true) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Checkbox(onCheckedChange = {}, enabled = true, checked = false, indeterminate = true, hasError = true) + Checkbox(onCheckedChange = {}, enabled = false, checked = false, indeterminate = true, hasError = true) + } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenu.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenu.kt index 175c0fb402..c0160bb7f8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenu.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenu.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import io.element.android.libraries.theme.ElementTheme -private val minMenuWidth = 200.dp +// Figma designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=1032%3A44063&mode=design&t=rsNegTbEVLYAXL76-1 @Composable fun DropdownMenu( @@ -38,19 +38,17 @@ fun DropdownMenu( properties: PopupProperties = PopupProperties(focusable = true), content: @Composable ColumnScope.() -> Unit ) { - val bgColor = if (ElementTheme.isLightTheme) { - ElementTheme.materialColors.background - } else { - ElementTheme.colors.bgSubtlePrimary - } + // Note: the internal shape corner radius should be 8dp, but there is a 4p value hardcoded in the internal Surface component androidx.compose.material3.DropdownMenu( expanded = expanded, onDismissRequest = onDismissRequest, modifier = modifier - .background(color = bgColor) + .background(color = ElementTheme.colors.bgCanvasDefault) .widthIn(min = minMenuWidth), offset = offset, properties = properties, content = content ) } + +private val minMenuWidth = 200.dp diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenuItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenuItem.kt index b8c18f99c6..1e1ccf8d30 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenuItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/DropdownMenuItem.kt @@ -17,20 +17,26 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowRight import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults -import androidx.compose.material3.MenuItemColors import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.theme.ElementTheme +// Figma designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=1032%3A44063&mode=design&t=rsNegTbEVLYAXL76-1 + @Composable fun DropdownMenuItem( text: @Composable () -> Unit, @@ -39,34 +45,37 @@ fun DropdownMenuItem( leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, enabled: Boolean = true, - colors: MenuItemColors = MenuDefaults.itemColors(), - contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { androidx.compose.material3.DropdownMenuItem( - text = text, + text = { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) { + text() + } + }, onClick = onClick, modifier = modifier, leadingIcon = leadingIcon, trailingIcon = trailingIcon, enabled = enabled, - colors = colors, - contentPadding = contentPadding, + colors = DropDownMenuItemDefaults.colors(), + contentPadding = DropDownMenuItemDefaults.contentPadding, interactionSource = interactionSource ) } -@Composable -fun DropdownMenuItemText( - text: String, - modifier: Modifier = Modifier, -) { - Text( - text = text, - color = ElementTheme.materialColors.primary, - style = ElementTheme.typography.fontBodyLgRegular, - modifier = modifier, +internal object DropDownMenuItemDefaults { + @Composable + fun colors() = MenuDefaults.itemColors( + textColor = ElementTheme.colors.textPrimary, + leadingIconColor = ElementTheme.colors.iconPrimary, + trailingIconColor = ElementTheme.colors.iconSecondary, + disabledTextColor = ElementTheme.colors.textDisabled, + disabledLeadingIconColor = ElementTheme.colors.iconDisabled, + disabledTrailingIconColor = ElementTheme.colors.iconDisabled, ) + + val contentPadding = PaddingValues(all = 12.dp) } @Preview(group = PreviewGroup.Menus) @@ -75,10 +84,36 @@ internal fun DropdownMenuItemPreview() = ElementThemedPreview { ContentToPreview @Composable private fun ContentToPreview() { - DropdownMenuItem( - text = { DropdownMenuItemText(text = "Item") }, - onClick = {}, - leadingIcon = { Icon(Icons.Default.BugReport, contentDescription = null) }, - trailingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, - ) + Column { + DropdownMenuItem( + text = { Text(text = "Item") }, + onClick = {}, + trailingIcon = { Icon(Icons.Default.ArrowRight, contentDescription = null) }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(text = "Item") }, + onClick = {}, + leadingIcon = { Icon(Icons.Default.BugReport, contentDescription = null) }, + ) + DropdownMenuItem( + text = { Text(text = "Item") }, + onClick = {}, + leadingIcon = { Icon(Icons.Default.BugReport, contentDescription = null) }, + trailingIcon = { Icon(Icons.Default.ArrowRight, contentDescription = null) }, + ) + DropdownMenuItem( + text = { Text(text = "Item") }, + onClick = {}, + enabled = false, + leadingIcon = { Icon(Icons.Default.BugReport, contentDescription = null) }, + trailingIcon = { Icon(Icons.Default.ArrowRight, contentDescription = null) }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(text = "Multiline\nItem") }, + onClick = {}, + trailingIcon = { Icon(Icons.Default.ArrowRight, contentDescription = null) }, + ) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt similarity index 91% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt index a8d64d271d..1a3f91a431 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Divider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt @@ -30,12 +30,12 @@ import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup @Composable -fun Divider( +fun HorizontalDivider( modifier: Modifier = Modifier, thickness: Dp = ElementDividerDefaults.thickness, color: Color = DividerDefaults.color, ) { - androidx.compose.material3.Divider( + androidx.compose.material3.HorizontalDivider( modifier = modifier, thickness = thickness, color = color, @@ -48,7 +48,7 @@ object ElementDividerDefaults { @Preview(group = PreviewGroup.Dividers) @Composable -internal fun DividerPreview() = ElementThemedPreview { +internal fun HorizontalDividerPreview() = ElementThemedPreview { Box(Modifier.padding(vertical = 10.dp), contentAlignment = Alignment.Center) { ContentToPreview() } @@ -56,5 +56,5 @@ internal fun DividerPreview() = ElementThemedPreview { @Composable private fun ContentToPreview() { - Divider() + HorizontalDivider() } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconButton.kt index 14cc6b62ce..7063ab1eb1 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/IconButton.kt @@ -17,16 +17,22 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme + +// Figma designs: https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=1182%3A48861&mode=design&t=Shlcvznm1oUyqGC2-1 @Composable fun IconButton( @@ -36,11 +42,15 @@ fun IconButton( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, content: @Composable () -> Unit ) { + val colors = IconButtonDefaults.iconButtonColors( + contentColor = LocalContentColor.current, + disabledContentColor = ElementTheme.colors.iconDisabled, + ) androidx.compose.material3.IconButton( onClick = onClick, modifier = modifier, enabled = enabled, - colors = IconButtonDefaults.iconButtonColors(), + colors = colors, interactionSource = interactionSource, content = content, ) @@ -53,12 +63,26 @@ internal fun IconButtonPreview() = @Composable private fun ContentToPreview() { - Row { - IconButton(onClick = {}) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "") + Column { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.iconPrimary) { + Row { + IconButton(onClick = {}) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") + } + IconButton(enabled = false, onClick = {}) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") + } + } } - IconButton(enabled = false, onClick = {}) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "") + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.iconSecondary) { + Row { + IconButton(onClick = {}) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") + } + IconButton(enabled = false, onClick = {}) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "") + } + } } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/LinearProgressIndicator.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/LinearProgressIndicator.kt new file mode 100644 index 0000000000..54985eaa51 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/LinearProgressIndicator.kt @@ -0,0 +1,90 @@ +/* + * 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.libraries.designsystem.theme.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup + +@Composable +fun LinearProgressIndicator( + progress: Float, + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.linearColor, + trackColor: Color = ProgressIndicatorDefaults.linearTrackColor, + strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap, +) { + androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + progress = progress, + color = color, + trackColor = trackColor, + strokeCap = strokeCap, + ) +} + +@Composable +fun LinearProgressIndicator( + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.linearColor, + trackColor: Color = ProgressIndicatorDefaults.linearTrackColor, + strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap, +) { + if (LocalInspectionMode.current) { + // Use a determinate progress indicator to improve the preview rendering + androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + progress = 0.75F, + color = color, + trackColor = trackColor, + strokeCap = strokeCap, + ) + } else { + androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + color = color, + trackColor = trackColor, + strokeCap = strokeCap, + ) + } +} + +@Preview(group = PreviewGroup.Progress) +@Composable +internal fun LinearProgressIndicatorPreview() = ElementThemedPreview(vertical = false) { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Indeterminate progress + LinearProgressIndicator( + ) + // Fixed progress + LinearProgressIndicator( + progress = 0.90F + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt index d3cd2fee07..d868f49965 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt @@ -18,15 +18,21 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -43,7 +49,11 @@ fun MediumTopAppBar( title = title, modifier = modifier, navigationIcon = navigationIcon, - actions = actions, + actions = { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textActionPrimary) { + actions() + } + }, windowInsets = windowInsets, colors = colors, scrollBehavior = scrollBehavior, @@ -58,5 +68,14 @@ internal fun MediumTopAppBarPreview() = @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ContentToPreview() { - MediumTopAppBar(title = { Text(text = "Title") }) + MediumTopAppBar( + title = { Text(text = "Title") }, + navigationIcon = { BackButton(onClick = {}) }, + actions = { + TextButton(text = "Action", onClick = {}) + IconButton(onClick = {}) { + Icon(imageVector = Icons.Default.Share, contentDescription = null) + } + } + ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt index 57b0612ad7..03e5c06eef 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt @@ -100,7 +100,7 @@ private fun ContentToPreview() { ) { ModalBottomSheet( onDismissRequest = {}, - sheetState = sheetStateForPreview, + sheetState = sheetStateForPreview(), ) { Text( text = "Sheet Content", diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt index 2f40a90615..30e5882f26 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt @@ -14,6 +14,9 @@ * limitations under the License. */ +// This is actually expected, as we should remove this component soon and use ModalBottomSheet instead +@file:Suppress("UsingMaterialAndMaterial3Libraries") + package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.background @@ -40,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -111,7 +115,7 @@ private fun ContentToPreview() { ModalBottomSheetLayout( modifier = Modifier.height(140.dp), displayHandle = true, - sheetState = ModalBottomSheetState(ModalBottomSheetValue.Expanded), + sheetState = ModalBottomSheetState(ModalBottomSheetValue.Expanded, density = LocalDensity.current), sheetContent = { Text(text = "Sheet Content", modifier = Modifier .padding(start = 16.dp, end = 16.dp, bottom = 20.dp) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedButton.kt deleted file mode 100644 index fa7ee261f6..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/OutlinedButton.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * 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.libraries.designsystem.theme.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ButtonElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.element.android.libraries.designsystem.preview.ElementThemedPreview -import io.element.android.libraries.designsystem.preview.PreviewGroup - -@Composable -fun OutlinedButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - shape: Shape = ElementOutlinedButtonDefaults.shape, - colors: ButtonColors = ElementOutlinedButtonDefaults.buttonColors(), - elevation: ButtonElevation? = ElementOutlinedButtonDefaults.buttonElevation(), - border: BorderStroke? = ElementOutlinedButtonDefaults.border, - contentPadding: PaddingValues = ElementOutlinedButtonDefaults.ContentPadding, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - content: @Composable RowScope.() -> Unit -) { - androidx.compose.material3.Button( - onClick = onClick, - modifier = modifier, - enabled = enabled, - shape = shape, - colors = colors, - elevation = elevation, - border = border, - contentPadding = contentPadding, - interactionSource = interactionSource, - content = content, - ) -} - -object ElementOutlinedButtonDefaults { - val ContentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp) - val shape: Shape @Composable get() = ButtonDefaults.outlinedShape - val border: BorderStroke @Composable get() = ButtonDefaults.outlinedButtonBorder - @Composable - fun buttonElevation(): ButtonElevation = ButtonDefaults.buttonElevation() - - @Composable - fun buttonColors(): ButtonColors = ButtonDefaults.outlinedButtonColors() - - -} - -@Preview(group = PreviewGroup.Buttons) -@Composable -internal fun OutlinedButtonsPreview() = ElementThemedPreview { ContentToPreview() } - -@Composable -private fun ContentToPreview() { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = {}, enabled = true) { - Text(text = "Click me! - Enabled") - } - OutlinedButton(onClick = {}, enabled = false) { - Text(text = "Click me! - Disabled") - } - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt index 6b0c1b377e..a8b186a6b2 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt @@ -17,15 +17,21 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.material3.RadioButtonColors import androidx.compose.material3.RadioButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme + +// Designs in https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=425%3A24202&mode=design&t=qb99xBP5mwwCtGkN-1 @Composable fun RadioButton( @@ -33,7 +39,7 @@ fun RadioButton( onClick: (() -> Unit)?, modifier: Modifier = Modifier, enabled: Boolean = true, - colors: RadioButtonColors = RadioButtonDefaults.colors(), + colors: RadioButtonColors = compoundRadioButtonColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { androidx.compose.material3.RadioButton( @@ -46,6 +52,15 @@ fun RadioButton( ) } +@Composable +internal fun compoundRadioButtonColors(): RadioButtonColors { + return RadioButtonDefaults.colors( + unselectedColor = ElementTheme.colors.borderInteractivePrimary, + disabledUnselectedColor = ElementTheme.colors.borderDisabled, + disabledSelectedColor = ElementTheme.colors.iconDisabled, + ) +} + @Preview(group = PreviewGroup.Toggles) @Composable internal fun RadioButtonPreview() = ElementThemedPreview(vertical = false) { ContentToPreview() } @@ -53,9 +68,13 @@ internal fun RadioButtonPreview() = ElementThemedPreview(vertical = false) { Con @Composable private fun ContentToPreview() { Column { - RadioButton(selected = false, onClick = {}) - RadioButton(selected = true, onClick = {}) - RadioButton(selected = false, enabled = false, onClick = {}) - RadioButton(selected = true, enabled = false, onClick = {}) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + RadioButton(selected = false, onClick = {}) + RadioButton(selected = false, enabled = false, onClick = {}) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + RadioButton(selected = true, onClick = {}) + RadioButton(selected = true, enabled = false, onClick = {}) + } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt index c129499a56..1eb73532d6 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt @@ -62,7 +62,7 @@ fun SearchBar( showBackButton: Boolean = true, resultState: SearchBarResultState = SearchBarResultState.NotSearching(), shape: Shape = SearchBarDefaults.inputFieldShape, - tonalElevation: Dp = SearchBarDefaults.Elevation, + tonalElevation: Dp = SearchBarDefaults.TonalElevation, windowInsets: WindowInsets = SearchBarDefaults.windowInsets, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, inactiveColors: SearchBarColors = ElementSearchBarDefaults.inactiveColors(), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Snackbar.kt new file mode 100644 index 0000000000..d2969fc3e9 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Snackbar.kt @@ -0,0 +1,147 @@ +/* + * 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.libraries.designsystem.theme.components + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.SnackbarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.button.ButtonVisuals +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.theme.SnackBarLabelColorDark +import io.element.android.libraries.theme.SnackBarLabelColorLight + +@Composable +fun Snackbar( + message: String, + modifier: Modifier = Modifier, + action: ButtonVisuals? = null, + dismissAction: ButtonVisuals? = null, + actionOnNewLine: Boolean = false, + shape: Shape = RoundedCornerShape(8.dp), + containerColor: Color = SnackbarDefaults.color, + contentColor: Color = ElementTheme.materialColors.inverseOnSurface, + actionContentColor: Color = actionContentColor(), + dismissActionContentColor: Color = SnackbarDefaults.dismissActionContentColor, +) { + Snackbar( + modifier = modifier, + action = action?.let { @Composable { it.Composable() } }, + dismissAction = dismissAction?.let { @Composable { it.Composable() } }, + actionOnNewLine = actionOnNewLine, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + actionContentColor = actionContentColor, + dismissActionContentColor = dismissActionContentColor, + content = { Text(text = message) }, + ) +} + +@Composable +fun Snackbar( + modifier: Modifier = Modifier, + action: @Composable (() -> Unit)? = null, + dismissAction: @Composable (() -> Unit)? = null, + actionOnNewLine: Boolean = false, + shape: Shape = RoundedCornerShape(8.dp), + containerColor: Color = SnackbarDefaults.color, + contentColor: Color = ElementTheme.materialColors.inverseOnSurface, + actionContentColor: Color = actionContentColor(), + dismissActionContentColor: Color = SnackbarDefaults.dismissActionContentColor, + content: @Composable () -> Unit +) { + androidx.compose.material3.Snackbar( + modifier = modifier, + action = action, + dismissAction = dismissAction, + actionOnNewLine = actionOnNewLine, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + actionContentColor = actionContentColor, + dismissActionContentColor = dismissActionContentColor, + content = content, + ) +} + +// TODO this color is temporary, an `inverse` version should be added to the semantic colors instead +@Composable +private fun actionContentColor(): Color { + return if (ElementTheme.isLightTheme) { + SnackBarLabelColorLight + } else { + SnackBarLabelColorDark + } +} + +@Preview(name = "Snackbar", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarPreview() { + ElementThemedPreview { + Snackbar(message = "Snackbar supporting text") + } +} + +@Preview(name = "Snackbar with action", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarWithActionPreview() { + ElementThemedPreview { + Snackbar(message = "Snackbar supporting text", action = ButtonVisuals.Text("Action", {})) + } +} + +@Preview(name = "Snackbar with action and close button", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarWithActionAndCloseButtonPreview() { + ElementThemedPreview { + Snackbar( + message = "Snackbar supporting text", + action = ButtonVisuals.Text("Action", {}), + dismissAction = ButtonVisuals.Icon(IconSource.Vector(Icons.Default.Close), {}) + ) + } +} + +@Preview(name = "Snackbar with action on new line", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarWithActionOnNewLinePreview() { + ElementThemedPreview { + Snackbar(message = "Snackbar supporting text", action = ButtonVisuals.Text("Action", {}), actionOnNewLine = true) + } +} + +@Preview(name = "Snackbar with action and close button on new line", group = PreviewGroup.Snackbars) +@Composable +internal fun SnackbarWithActionOnNewLineAndCloseButtonPreview() { + ElementThemedPreview { + Snackbar( + message = "Snackbar supporting text", + action = ButtonVisuals.Text("Action", {}), + dismissAction = ButtonVisuals.Icon(IconSource.Vector(Icons.Default.Close), {}), + actionOnNewLine = true + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Switch.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Switch.kt new file mode 100644 index 0000000000..ab4c9dee05 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Switch.kt @@ -0,0 +1,89 @@ +/* + * 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.libraries.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme +import androidx.compose.material3.Switch as Material3Switch + +// Designs in https://www.figma.com/file/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?type=design&node-id=425%3A24203&mode=design&t=qb99xBP5mwwCtGkN-1 + +@Composable +fun Switch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: SwitchColors = compoundSwitchColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumbContent: (@Composable () -> Unit)? = null, +) { + Material3Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + thumbContent = thumbContent + ) +} + +@Composable +internal fun compoundSwitchColors() = SwitchDefaults.colors( + uncheckedThumbColor = ElementTheme.colors.bgActionPrimaryRest, + uncheckedTrackColor = Color.Transparent, + disabledUncheckedBorderColor = ElementTheme.colors.borderDisabled, + disabledUncheckedThumbColor = ElementTheme.colors.iconDisabled, + disabledCheckedTrackColor = ElementTheme.colors.iconDisabled, + disabledCheckedBorderColor = ElementTheme.colors.iconDisabled, +) + +@Preview(group = PreviewGroup.Toggles) +@Composable +internal fun SwitchPreview() { + var checked by remember { mutableStateOf(false) } + ElementThemedPreview { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Switch(checked = checked, onCheckedChange = { checked = !checked }) + Switch(enabled = false, checked = checked, onCheckedChange = { checked = !checked }) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Switch(checked = !checked, onCheckedChange = { checked = !checked }) + Switch(enabled = false, checked = !checked, onCheckedChange = { checked = !checked }) + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextButton.kt deleted file mode 100644 index 3b4b50a5e7..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextButton.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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.libraries.designsystem.theme.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ButtonElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.tooling.preview.Preview -import io.element.android.libraries.designsystem.preview.ElementThemedPreview -import io.element.android.libraries.designsystem.preview.PreviewGroup - -@Composable -fun TextButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - shape: Shape = ButtonDefaults.textShape, - colors: ButtonColors = ButtonDefaults.textButtonColors(), - elevation: ButtonElevation? = null, - border: BorderStroke? = null, - contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - content: @Composable RowScope.() -> Unit -) { - androidx.compose.material3.TextButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - shape = shape, - colors = colors, - elevation = elevation, - border = border, - contentPadding = contentPadding, - interactionSource = interactionSource, - content = content, - ) -} - -@Preview(group = PreviewGroup.Buttons) -@Composable -internal fun TextButtonPreview() = ElementThemedPreview { ContentToPreview() } - -@Composable -private fun ContentToPreview() { - Column { - TextButton(onClick = {}, enabled = true) { - Text(text = "Click me! - Enabled") - } - TextButton(onClick = {}, enabled = false) { - Text(text = "Click me! - Disabled") - } - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt index 23848ef76d..93d11e8c9a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt @@ -18,15 +18,21 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.theme.ElementTheme @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -43,7 +49,11 @@ fun TopAppBar( title = title, modifier = modifier, navigationIcon = navigationIcon, - actions = actions, + actions = { + CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textActionPrimary) { + actions() + } + }, windowInsets = windowInsets, colors = colors, scrollBehavior = scrollBehavior, @@ -58,5 +68,14 @@ internal fun TopAppBarPreview() = @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ContentToPreview() { - TopAppBar(title = { Text(text = "Title") }) + TopAppBar( + title = { Text(text = "Title") }, + navigationIcon = { BackButton(onClick = {}) }, + actions = { + TextButton(text = "Action", onClick = {}) + IconButton(onClick = {}) { + Icon(imageVector = Icons.Default.Share, contentDescription = null) + } + } + ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt index a422c5e729..45b5eb39be 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt @@ -23,7 +23,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import io.element.android.libraries.designsystem.components.dialogs.AlertDialogContent +import io.element.android.libraries.designsystem.theme.components.AlertDialogContent import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewGroup @@ -44,7 +44,7 @@ internal fun DatePickerPreviewDark() { @Composable private fun ContentToPreview() { val state = rememberDatePickerState( - initialSelectedDateMillis = 1672578000000L, + initialSelectedDateMillis = 1_672_578_000_000L, ) AlertDialogContent( buttons = { /*TODO*/ }, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/MenuPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/MenuPreview.kt index f1c2cd444d..2ca04f1008 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/MenuPreview.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/MenuPreview.kt @@ -30,7 +30,6 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem -import io.element.android.libraries.designsystem.theme.components.DropdownMenuItemText import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text @@ -39,9 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.Text internal fun MenuPreview() { ElementThemedPreview { var isExpanded by remember { mutableStateOf(false) } - Button(onClick = { isExpanded = !isExpanded }) { - Text("Toggle") - } + Button(text = "Toggle", onClick = { isExpanded = !isExpanded }) DropdownMenu(expanded = isExpanded, onDismissRequest = { isExpanded = false }) { for (i in 0..5) { val leadingIcon: @Composable (() -> Unit)? = if (i in 2..3) { @@ -60,7 +57,7 @@ internal fun MenuPreview() { null } DropdownMenuItem( - text = { DropdownMenuItemText(text = "Item $i") }, + text = { Text(text = "Item $i") }, onClick = { isExpanded = false }, leadingIcon = leadingIcon, trailingIcon = trailingIcon, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/SwitchPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/SwitchPreview.kt deleted file mode 100644 index 11491a0a1c..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/SwitchPreview.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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.libraries.designsystem.theme.components.previews - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material3.Switch -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.element.android.libraries.designsystem.preview.ElementThemedPreview -import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Icon - -@Preview(group = PreviewGroup.Toggles) -@Composable -internal fun SwitchPreview() { - ElementThemedPreview { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - var checked by remember { mutableStateOf(false) } - Switch(checked = checked, onCheckedChange = { checked = !checked }) - Switch(checked = checked, onCheckedChange = { checked = !checked }, thumbContent = { - Icon(imageVector = Icons.Outlined.Check, contentDescription = null) - }) - Switch(checked = checked, enabled = false, onCheckedChange = { checked = !checked }) - Switch(checked = checked, enabled = false, onCheckedChange = { checked = !checked }, thumbContent = { - Icon(imageVector = Icons.Outlined.Check, contentDescription = null) - }) - } - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt index 79f0fffbee..7aae42ed0e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt @@ -24,7 +24,7 @@ import androidx.compose.material3.TimePickerLayoutType import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview -import io.element.android.libraries.designsystem.components.dialogs.AlertDialogContent +import io.element.android.libraries.designsystem.theme.components.AlertDialogContent import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementThemedPreview diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LogCompositions.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LogCompositions.kt index dcbef866fe..2618a5de82 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LogCompositions.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LogCompositions.kt @@ -28,10 +28,12 @@ import timber.log.Timber @Composable fun LogCompositions(tag: String, msg: String) { if (BuildConfig.DEBUG) { - val ref = remember { Ref(0) } + val ref = remember { Ref() } SideEffect { ref.value++ } Timber.tag(tag).d("Compositions: $msg ${ref.value}") } } -class Ref(var value: Int) +private class Ref { + var value: Int = 0 +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt index f4e44779d1..4513a90914 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -17,6 +17,8 @@ package io.element.android.libraries.designsystem.utils import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable @@ -25,7 +27,11 @@ import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.button.ButtonVisuals +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.Snackbar import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -65,6 +71,19 @@ fun SnackbarDispatcher.collectSnackbarMessageAsState(): State return snackbarMessage.collectAsState(initial = null) } +@Composable +fun SnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) { + androidx.compose.material3.SnackbarHost(hostState, modifier) { data -> + Snackbar( + message = data.visuals.message, + action = data.visuals.actionLabel?.let { ButtonVisuals.Text(it, data::performAction) }, + dismissAction = if (data.visuals.withDismissAction) { + ButtonVisuals.Icon(IconSource.Vector(Icons.Default.Close), data::dismiss) + } else null, + ) + } +} + @Composable fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState { val snackbarHostState = remember { SnackbarHostState() } diff --git a/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml b/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml deleted file mode 100644 index 96a220a5cd..0000000000 --- a/libraries/designsystem/src/main/res/drawable/ic_baseline_reply_24.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - diff --git a/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml b/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml deleted file mode 100644 index 739053947d..0000000000 --- a/libraries/designsystem/src/main/res/drawable/ic_content_arrow_forward.xml +++ /dev/null @@ -1,27 +0,0 @@ - - -- - diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt index f1af61c8e7..0736cf61ce 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatter.kt @@ -37,6 +37,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageType import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent @@ -94,6 +96,7 @@ class DefaultRoomLastMessageFormatter @Inject constructor( is StateContent -> { stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList) } + is PollContent, is PollEndContent, // TODO Polls: handle last message is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> { prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom) } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt index 8f89233a31..42d8aae083 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -26,6 +26,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent @@ -63,6 +65,8 @@ class DefaultTimelineEventFormatter @Inject constructor( } RedactedContent, is StickerContent, + is PollContent, + is PollEndContent, is UnableToDecryptContent, is MessageContent, is FailedToParseMessageLikeContent, diff --git a/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..4c1defbb68 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,57 @@ + +- + diff --git a/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..45ab0acee1 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,37 @@ + +"(аватар тоже был изменен)" +"%1$s сменили свой аватар" +"Вы сменили аватар" +"%1$s изменил свое отображаемое имя с %2$s на %3$s" +"Вы изменили свое отображаемое имя с %1$s на %2$s" +"%1$s удалил свое отображаемое имя (оно было %2$s)" +"Вы удалили свое отображаемое имя (оно было %1$s)" +"%1$s установили свое отображаемое имя на %2$s" +"Вы установили отображаемое имя на %1$s" +"%1$s изменил аватар комнаты" +"Вы изменили аватар комнаты" +"%1$s удалил аватар комнаты" +"Вы удалили аватар комнаты" +"%1$s заблокирован %2$s" +"Вы заблокировали %1$s" +"%1$s создал комнату" +"Вы создали комнату" +"%1$s пригласил %2$s" +"%1$s принял приглашение" +"Вы приняли приглашение" +"Вы пригласили %1$s" +"Пользователь %1$s пригласил вас" +"%1$s присоединился к комнате" +"Вы вошли в комнату" +"%1$s запросил присоединение" +"%1$s разрешил %2$s присоединиться" +"%1$s разрешил вам присоединиться" +"Вы запросили присоединение" +"%1$s отклонил запрос %2$s на присоединение" +"Вы отклонили запрос %1$s на присоединение" +"%1$s отклонил ваш запрос на присоединение" +"%1$s больше не заинтересован в присоединении" +"Вы отменили запрос на присоединение" +"%1$s покинул комнату" +"Вы вышли из комнаты" +"%1$s изменил название комнаты на: %2$s" +"Вы изменили название комнаты на: %1$s" +"%1$s удалил название комнаты" +"Вы удалили название комнаты" +"%1$s отклонил приглашение" +"Вы отклонили приглашение" +"%1$s удалил %2$s" +"Вы удалили %1$s" +"%1$s отправила приглашение %2$s присоединиться к комнате" +"Вы отправили приглашение присоединиться к комнате %1$s" +"%1$s отозвал приглашение %2$s присоединиться к комнате" +"Вы отозвали приглашение %1$s присоединиться к комнате" +"%1$s изменил тему на: %2$s" +"Вы изменили тему на: %1$s" +"%1$s удалил тему комнаты" +"Вы удалили тему комнаты" +"%1$s разблокирован %2$s" +"Вы разблокировали %1$s" +"%1$s внес неизвестное изменение в составе" ++ diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 4c1bfed33a..176bacb2c4 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -25,5 +25,11 @@ enum class FeatureFlags( LocationSharing( key = "feature.locationsharing", title = "Allow user to share location", + ), + Polls( + key = "feature.polls", + title = "Polls", + description = "Render poll events in the timeline", + defaultValue = false, ) } diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt index d226885495..83913cbac5 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/BuildtimeFeatureFlagProvider.kt @@ -30,6 +30,7 @@ class BuildtimeFeatureFlagProvider @Inject constructor() : return if (feature is FeatureFlags) { when (feature) { FeatureFlags.LocationSharing -> true + FeatureFlags.Polls -> false } } else { false diff --git a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt index 87d0125278..f0fe6b3c04 100644 --- a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/FeatureListView.kt @@ -54,6 +54,7 @@ fun FeaturePreferenceView( ) { PreferenceCheckbox( title = feature.title, + supportingText = feature.description, isChecked = feature.isEnabled, modifier = modifier, onCheckedChange = onCheckedChange diff --git a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModel.kt b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModel.kt index 5a3eecebe0..9176eb168a 100644 --- a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModel.kt +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModel.kt @@ -19,5 +19,6 @@ package io.element.android.libraries.featureflag.ui.model data class FeatureUiModel( val key: String, val title: String, + val description: String?, val isEnabled: Boolean ) diff --git a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModelProvider.kt b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModelProvider.kt index 233331d46d..12ff3b4029 100644 --- a/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModelProvider.kt +++ b/libraries/featureflag/ui/src/main/kotlin/io/element/android/libraries/featureflag/ui/model/FeatureUiModelProvider.kt @@ -21,7 +21,7 @@ import kotlinx.collections.immutable.persistentListOf fun aFeatureUiModelList(): ImmutableList"%1$s將他的顯示名稱從%2$s變更為%3$s" +"您將您的顯示名稱從%1$s1變更為%2$s" +"%1$s的顯示名稱已被本人移除(原為%2$s)" +"您的顯示名稱已被您移除(原為%1$s)" +"%1$s將他的顯示名稱設為%2$s" +"您將您的顯示名稱設為%1$s" +"%1$s建立此聊天室" +"您建立此聊天室" +"%1$s邀請%2$s" +"%1$s接受邀請" +"您接受邀請" +"您邀請%1$s" +"%1$s邀請您" +"%1$s加入聊天室" +"您加入聊天室" +"%1$s請求加入" +"您請求加入" +"%1$s拒絕%2$s的加入請求" +"您拒絕%1$s的加入請求" +"%1$s拒絕您的加入請求" +"%1$s離開聊天室" +"您離開聊天室" +"%1$s將聊天室名稱變更為%2$s" +"您將聊天室名稱變更為%1$s" +"聊天室名稱已被%1$s移除" +"聊天室名稱已被您移除" +"%2$s已被%1$s移除" +"%1$s已被您移除" +"%1$s邀請%2$s加入聊天室" +"您邀請%1$s加入聊天室" +"%1$s將主題變更為%2$s" +"您將主題變更為%1$s" +"聊天室主題已被%1$s移除" +"聊天室主題已被您移除" +{ return persistentListOf( - FeatureUiModel("key1", "Display State Events", true), - FeatureUiModel("key2", "Display Room Events", false) + FeatureUiModel("key1", "Display State Events", "Show state events in the timeline", true), + FeatureUiModel("key2", "Display Room Events", null, false), ) } diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt index c67be52c1c..5af79e7524 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt @@ -236,7 +236,7 @@ private fun MapView.lifecycleObserver(previousState: MutableState { //handled in onDispose } - else -> throw IllegalStateException() + Lifecycle.Event.ON_ANY -> error("ON_ANY should never be used") } previousState.value = event } diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index 4ff5f3450d..a1d508885a 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -18,7 +18,7 @@ plugins { id("io.element.android-library") id("kotlin-parcelize") alias(libs.plugins.anvil) - kotlin("plugin.serialization") version "1.8.22" + kotlin("plugin.serialization") version "1.9.0" } android { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 747de5f554..67c0625a91 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser @@ -35,7 +35,7 @@ import java.io.Closeable interface MatrixClient : Closeable { val sessionId: SessionId - val roomSummaryDataSource: RoomSummaryDataSource + val roomListService: RoomListService val mediaLoader: MatrixMediaLoader suspend fun getRoom(roomId: RoomId): MatrixRoom? suspend fun findDM(userId: UserId): MatrixRoom? diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt index bc0f0c04bc..a668448752 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt @@ -30,7 +30,8 @@ object MatrixPatterns { // regex pattern to find matrix user ids in a string. // See https://matrix.org/docs/spec/appendices#historical-user-ids - private const val MATRIX_USER_IDENTIFIER_REGEX = "@[A-Z0-9\\x21-\\x39\\x3B-\\x7F]+$DOMAIN_REGEX" + // Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec. + private const val MATRIX_USER_IDENTIFIER_REGEX = "^@.*?$DOMAIN_REGEX$" val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) // regex pattern to find room ids in a string. @@ -42,7 +43,8 @@ object MatrixPatterns { private val PATTERN_CONTAIN_MATRIX_ALIAS = MATRIX_ROOM_ALIAS_REGEX.toRegex(RegexOption.IGNORE_CASE) // regex pattern to find message ids in a string. - private const val MATRIX_EVENT_IDENTIFIER_REGEX = "\\$[A-Z0-9]+$DOMAIN_REGEX" + // Sadly, we need to relax the regex pattern a bit as there already exist some ids that don't match the spec. + private const val MATRIX_EVENT_IDENTIFIER_REGEX = "^\\$.+$DOMAIN_REGEX$" private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE) // regex pattern to find message ids in a string. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaUploadHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaUploadHandler.kt new file mode 100644 index 0000000000..17d204715e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaUploadHandler.kt @@ -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.libraries.matrix.api.media + +/** + * This is an abstraction over the Rust SDK's `SendAttachmentJoinHandle` which allows us to either [await] the upload process or [cancel] it. + */ +interface MediaUploadHandler { + /** Await the upload process to finish. */ + suspend fun await(): Result + + /** Cancel the upload process. */ + fun cancel() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt index 4ded947d56..639509a15a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -23,7 +23,6 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.timeline.item.event.MessageType data class NotificationData( - val senderId: UserId, val eventId: EventId, val roomId: RoomId, val senderAvatarUrl: String?, @@ -33,14 +32,10 @@ data class NotificationData( val isDirect: Boolean, val isEncrypted: Boolean, val isNoisy: Boolean, - val event: NotificationEvent, -) - -data class NotificationEvent( val timestamp: Long, val content: NotificationContent, // For images for instance - val contentUrl: String? + val contentUrl: String?, ) sealed interface NotificationContent { @@ -61,6 +56,7 @@ sealed interface NotificationContent { ) : MessageLike object RoomEncrypted : MessageLike data class RoomMessage( + val senderId: UserId, val messageType: MessageType ) : MessageLike object RoomRedaction : MessageLike diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt index 2046252930..972873ab38 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt @@ -21,5 +21,5 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId interface NotificationService { - fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId, filterByPushRules: Boolean): Result + suspend fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt index fc900e4bbb..ba9cdc1e80 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt @@ -95,9 +95,9 @@ object PermalinkParser { return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) { try { val signValidUri = Uri.parse(signUrl) - val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException() - val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException() - val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException() + val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException("missing `authority`") + val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException("missing `token`") + val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException("missing `private_key`") PermalinkData.RoomEmailInviteLink( roomId = identifier, email = email!!, @@ -137,7 +137,8 @@ object PermalinkParser { .parameterList .filter { it.mParameter == "via" - }.map { + } + .map { URLDecoder.decode(it.mValue, "UTF-8") } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollAnswer.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollAnswer.kt new file mode 100644 index 0000000000..2d4abaafb5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollAnswer.kt @@ -0,0 +1,22 @@ +/* + * 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.libraries.matrix.api.poll + +data class PollAnswer( + val id: String, + val text: String +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollKind.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollKind.kt new file mode 100644 index 0000000000..85bb7c0256 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/poll/PollKind.kt @@ -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.libraries.matrix.api.poll + +enum class PollKind { + /** Voters should see results as soon as they have voted. */ + Disclosed, + + /** Results should be only revealed when the poll is ended. */ + Undisclosed +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 1445f85228..f2c51c357e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimeline @@ -81,13 +82,13 @@ interface MatrixRoom : Closeable { suspend fun redactEvent(eventId: EventId, reason: String? = null): Result - suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result + suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result - suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result + suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result - suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result + suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result - suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result + suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result suspend fun toggleReaction(emoji: String, eventId: EventId): Result @@ -105,6 +106,8 @@ interface MatrixRoom : Closeable { suspend fun canUserInvite(userId: UserId): Result + suspend fun canUserRedact(userId: UserId): Result + suspend fun canUserSendState(userId: UserId, type: StateEventType): Result suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt index 852401bffc..e0ba452efe 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomPowerLevels.kt @@ -34,3 +34,9 @@ suspend fun MatrixRoom.canSendState(type: StateEventType): Result = can * Shortcut for calling [MatrixRoom.canUserSendMessage] with our own user. */ suspend fun MatrixRoom.canSendMessage(type: MessageEventType): Result = canUserSendMessage(sessionId, type) + +/** + * Shortcut for calling [MatrixRoom.canUserRedact] with our own user. + */ +suspend fun MatrixRoom.canRedact(): Result = canUserRedact(sessionId) + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummaryDataSource.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt similarity index 65% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummaryDataSource.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt index d677d56ed9..8714bc2c5c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummaryDataSource.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.api.room +package io.element.android.libraries.matrix.api.roomlist import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.StateFlow @@ -23,25 +23,34 @@ import kotlinx.coroutines.withTimeout import timber.log.Timber import kotlin.time.Duration -interface RoomSummaryDataSource { - +/** + * Holds some flows related to a specific set of rooms. + * Can be retrieved from [RoomListService] methods. + */ +interface RoomList { sealed class LoadingState { object NotLoaded : LoadingState() data class Loaded(val numberOfRooms: Int) : LoadingState() } - fun updateAllRoomsVisibleRange(range: IntRange) - fun allRoomsLoadingState(): StateFlow - fun allRooms(): StateFlow > - fun inviteRooms(): StateFlow
> + /** + * The list of room summaries as a flow. + */ + val summaries: StateFlow
> + + /** + * The loading state of the room list as a flow. + * This is useful to know if a specific set of rooms is loaded or not. + */ + val loadingState: StateFlow
} -suspend fun RoomSummaryDataSource.awaitAllRoomsAreLoaded(timeout: Duration = Duration.INFINITE) { +suspend fun RoomList.awaitLoaded(timeout: Duration = Duration.INFINITE) { try { Timber.d("awaitAllRoomsAreLoaded: wait") withTimeout(timeout) { - allRoomsLoadingState().firstOrNull { - it is RoomSummaryDataSource.LoadingState.Loaded + loadingState.firstOrNull { + it is RoomList.LoadingState.Loaded } } } catch (timeoutException: TimeoutCancellationException) { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt new file mode 100644 index 0000000000..99381d0e74 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt @@ -0,0 +1,56 @@ +/* + * 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.libraries.matrix.api.roomlist + +import kotlinx.coroutines.flow.StateFlow + +/** + * Entry point for the room list api. + * This service will provide different sets of rooms (all, invites, etc.). + * It requires the SyncService to be started to receive updates. + */ +interface RoomListService { + + sealed class State { + object Idle : State() + object Running : State() + object Error : State() + object Terminated : State() + } + + /** + * returns a [RoomList] object of all rooms we want to display. + * This will exclude some rooms like the invites, or spaces. + */ + fun allRooms(): RoomList + + /** + * returns a [RoomList] object of all invites. + */ + fun invites(): RoomList + + /** + * Will set the visible range of all rooms. + * This is useful to load more data when the user scrolls down. + */ + fun updateAllRoomsVisibleRange(range: IntRange) + + /** + * The state of the service as a flow. + */ + val state: StateFlow +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt similarity index 92% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt index 7dedd86b63..87cf2139d6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummary.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomSummary.kt @@ -14,9 +14,10 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.api.room +package io.element.android.libraries.matrix.api.roomlist import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.message.RoomMessage sealed interface RoomSummary { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt index 5271ec9bc0..994b35edc4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt @@ -27,7 +27,7 @@ interface SyncService { /** * Tries to stop the sync. If service is not syncing it has no effect. */ - fun stopSync(): Result + suspend fun stopSync(): Result /** * Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index 203fb30794..82c322668c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -23,6 +23,8 @@ import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.poll.PollAnswer +import io.element.android.libraries.matrix.api.poll.PollKind sealed interface EventContent @@ -44,7 +46,7 @@ sealed interface InReplyTo { /** The event details are available. */ data class Ready( val eventId: EventId, - val content: MessageContent, + val content: EventContent, val senderId: UserId, val senderDisplayName: String?, val senderAvatarUrl: String?, @@ -69,6 +71,19 @@ data class StickerContent( val url: String ) : EventContent +data class PollContent( + val question: String, + val kind: PollKind, + val maxSelections: ULong, + val answers: List , + val votes: Map >, + val endTime: ULong? +) : EventContent + +data class PollEndContent( + val startEventId: String +) : EventContent + data class UnableToDecryptContent( val data: Data ) : EventContent { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt index 8bea4b5330..a2e68d17d2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventReaction.kt @@ -16,10 +16,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event -import io.element.android.libraries.matrix.api.core.UserId - data class EventReaction( val key: String, - val count: Long, - val senderIds: List + val senders: List ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt new file mode 100644 index 0000000000..60398cffd5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ReactionSender.kt @@ -0,0 +1,31 @@ +/* + * 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.libraries.matrix.api.timeline.item.event + +import io.element.android.libraries.matrix.api.core.UserId + +/** + * The sender of a reaction. + * + * @property senderId the ID of the user who sent the reaction + * @property timestamp the timestamp the reaction was received on the origin homeserver + */ +data class ReactionSender( + val senderId: UserId, + val timestamp: Long +) + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt index 8bcd602b9f..6381cc7ed8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -17,64 +17,7 @@ package io.element.android.libraries.matrix.api.tracing data class TracingConfiguration( - val overrides: Map = emptyMap() -) { - - // Order should matters - private val targets: MutableMap = mutableMapOf( - Target.Common to LogLevel.Warn, - Target.Hyper to LogLevel.Warn, - Target.Sled to LogLevel.Warn, - Target.MatrixSdk.Root to LogLevel.Warn, - Target.MatrixSdk.Sled to LogLevel.Warn, - Target.MatrixSdk.Crypto to LogLevel.Debug, - Target.MatrixSdk.HttpClient to LogLevel.Debug, - Target.MatrixSdk.SlidingSync to LogLevel.Trace, - Target.MatrixSdk.BaseSlidingSync to LogLevel.Trace, - ) - - val filter: String - get() { - overrides.forEach { (target, logLevel) -> - targets[target] = logLevel - } - return targets.map { - if (it.key.filter.isEmpty()) { - it.value.filter - } else { - "${it.key.filter}=${it.value.filter}" - } - }.joinToString(separator = ",") - } -} - -sealed class Target(open val filter: String) { - object Common : Target("") - object Hyper : Target("hyper") - object Sled : Target("sled") - sealed class MatrixSdk(override val filter: String) : Target(filter) { - object Root : MatrixSdk("matrix_sdk") - object Sled : MatrixSdk("matrix_sdk_sled") - object Crypto: MatrixSdk("matrix_sdk_crypto") - object FFI : MatrixSdk("matrix_sdk_ffi") - object HttpClient : MatrixSdk("matrix_sdk::http_client") - object UniffiAPI : MatrixSdk("matrix_sdk_ffi::uniffi_api") - object SlidingSync : MatrixSdk("matrix_sdk::sliding_sync") - object BaseSlidingSync : MatrixSdk("matrix_sdk_base::sliding_sync") - } -} - -sealed class LogLevel(val filter: String) { - object Warn : LogLevel("warn") - object Trace : LogLevel("trace") - object Info : LogLevel("info") - object Debug : LogLevel("debug") - object Error : LogLevel("error") -} - -object TracingConfigurations { - val release = TracingConfiguration(overrides = mapOf(Target.Common to LogLevel.Info)) - val debug = TracingConfiguration(overrides = mapOf(Target.Common to LogLevel.Info)) - - fun custom(overrides: Map ) = TracingConfiguration(overrides) -} + val filterConfiguration: TracingFilterConfiguration, + val writesToLogcat: Boolean, + val writesToFilesConfiguration: WriteToFilesConfiguration, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt new file mode 100644 index 0000000000..21c6954c2a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingFilterConfiguration.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.tracing + +data class TracingFilterConfiguration( + val overrides: Map = emptyMap(), +) { + + // Order should matters + private val targetsToLogLevel: MutableMap = mutableMapOf( + Target.COMMON to LogLevel.Info, + Target.HYPER to LogLevel.Warn, + Target.MATRIX_SDK_CRYPTO to LogLevel.Debug, + Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.Debug, + Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.Trace, + Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.Trace, + Target.MATRIX_SDK_UI_TIMELINE to LogLevel.Info, + ) + + val filter: String + get() { + overrides.forEach { (target, logLevel) -> + targetsToLogLevel[target] = logLevel + } + return targetsToLogLevel.map { + if (it.key.filter.isEmpty()) { + it.value.filter + } else { + "${it.key.filter}=${it.value.filter}" + } + }.joinToString(separator = ",") + } +} + +enum class Target(open val filter: String) { + COMMON(""), + ELEMENT("elementx"), + HYPER("hyper"), + MATRIX_SDK_FFI("matrix_sdk_ffi"), + MATRIX_SDK_UNIFFI_API("matrix_sdk_ffi::uniffi_api"), + MATRIX_SDK_CRYPTO("matrix_sdk_crypto"), + MATRIX_SDK_HTTP_CLIENT("matrix_sdk::http_client"), + MATRIX_SDK_SLIDING_SYNC("matrix_sdk::sliding_sync"), + MATRIX_SDK_BASE_SLIDING_SYNC("matrix_sdk_base::sliding_sync"), + MATRIX_SDK_UI_TIMELINE("matrix_sdk_ui::timeline"), +} + +sealed class LogLevel(val filter: String) { + object Warn : LogLevel("warn") + object Trace : LogLevel("trace") + object Info : LogLevel("info") + object Debug : LogLevel("debug") + object Error : LogLevel("error") +} + +object TracingFilterConfigurations { + val release = TracingFilterConfiguration( + overrides = mapOf( + Target.COMMON to LogLevel.Info, + Target.ELEMENT to LogLevel.Debug + ), + ) + val debug = TracingFilterConfiguration( + overrides = mapOf( + Target.COMMON to LogLevel.Info, + Target.ELEMENT to LogLevel.Trace + ) + ) + + /** + * Use this method to create a custom configuration where all targets will have the same log level. + */ + fun custom(logLevel: LogLevel) = TracingFilterConfiguration(overrides = Target.values().associateWith { logLevel }) + + /** + * Use this method to override the log level of specific targets. + */ + fun custom(overrides: Map ) = TracingFilterConfiguration(overrides) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TracingConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt similarity index 60% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TracingConfiguration.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt index f32b18c9f2..4a74f83b20 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TracingConfiguration.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,13 +14,11 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.impl.tracing +package io.element.android.libraries.matrix.api.tracing -import io.element.android.libraries.matrix.api.tracing.TracingConfiguration import timber.log.Timber -fun setupTracing(tracingConfiguration: TracingConfiguration) { - val filter = tracingConfiguration.filter - Timber.v("Tracing config filter = $filter") - org.matrix.rustcomponents.sdk.setupTracing(filter) +interface TracingService { + fun setupTracing(tracingConfiguration: TracingConfiguration) + fun createTimberTree(): Timber.Tree } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt new file mode 100644 index 0000000000..cafa375a6a --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/WriteToFilesConfiguration.kt @@ -0,0 +1,22 @@ +/* + * 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.libraries.matrix.api.tracing + +sealed class WriteToFilesConfiguration { + object Disabled : WriteToFilesConfiguration() + data class Enabled(val directory: String, val filenamePrefix: String) : WriteToFilesConfiguration() +} diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 7786a3ee3f..e29d4b73db 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -17,7 +17,7 @@ plugins { id("io.element.android-library") alias(libs.plugins.anvil) - kotlin("plugin.serialization") version "1.8.22" + kotlin("plugin.serialization") version "1.9.0" } android { @@ -29,7 +29,7 @@ anvil { } dependencies { - // api(projects.libraries.rustsdk) + // implementation(projects.libraries.rustsdk) implementation(libs.matrix.sdk) implementation(projects.libraries.di) implementation(projects.libraries.androidutils) @@ -45,4 +45,5 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) testImplementation(projects.libraries.matrix.test) + testImplementation(libs.coroutines.test) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 640e0772a9..8f5cfa496b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -32,8 +32,8 @@ import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource -import io.element.android.libraries.matrix.api.room.awaitAllRoomsAreLoaded +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.awaitLoaded import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults @@ -45,9 +45,8 @@ import io.element.android.libraries.matrix.impl.notification.RustNotificationSer import io.element.android.libraries.matrix.impl.pushers.RustPushersService import io.element.android.libraries.matrix.impl.room.RoomContentForwarder import io.element.android.libraries.matrix.impl.room.RustMatrixRoom -import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource -import io.element.android.libraries.matrix.impl.room.roomOrNull -import io.element.android.libraries.matrix.impl.room.stateFlow +import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService +import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper @@ -90,20 +89,20 @@ class RustMatrixClient constructor( ) : MatrixClient { override val sessionId: UserId = UserId(client.userId()) - private val roomListService = syncService.roomListService() + private val innerRoomListService = syncService.roomListService() private val sessionDispatcher = dispatchers.io.limitedParallelism(64) private val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-${sessionId}") private val verificationService = RustSessionVerificationService() - private val rustSyncService = RustSyncService(syncService, roomListService.stateFlow(), sessionCoroutineScope) + private val rustSyncService = RustSyncService(syncService, sessionCoroutineScope) private val pushersService = RustPushersService( client = client, dispatchers = dispatchers, ) private val notificationClient = client.notificationClient().use { builder -> - builder.finish() + builder.filterByPushRules().finish() } - private val notificationService = RustNotificationService(notificationClient) + private val notificationService = RustNotificationService(sessionId, notificationClient, dispatchers, clock) private val isLoggingOut = AtomicBoolean(false) @@ -122,15 +121,15 @@ class RustMatrixClient constructor( } } - private val rustRoomSummaryDataSource: RustRoomSummaryDataSource = - RustRoomSummaryDataSource( - roomListService = roomListService, + private val rustRoomListService: RoomListService = + RustRoomListService( + innerRoomListService = innerRoomListService, sessionCoroutineScope = sessionCoroutineScope, dispatcher = sessionDispatcher, ) - override val roomSummaryDataSource: RoomSummaryDataSource - get() = rustRoomSummaryDataSource + override val roomListService: RoomListService + get() = rustRoomListService private val rustMediaLoader = RustMediaLoader(baseCacheDirectory, dispatchers, client) override val mediaLoader: MatrixMediaLoader @@ -138,7 +137,7 @@ class RustMatrixClient constructor( private val roomMembershipObserver = RoomMembershipObserver() - private val roomContentForwarder = RoomContentForwarder(roomListService) + private val roomContentForwarder = RoomContentForwarder(innerRoomListService) init { client.setDelegate(clientDelegate) @@ -147,35 +146,36 @@ class RustMatrixClient constructor( if (syncState == SyncState.Running) { onSlidingSyncUpdate() } - }.launchIn(sessionCoroutineScope) + } + .launchIn(sessionCoroutineScope) } - override suspend fun getRoom(roomId: RoomId): MatrixRoom? { + override suspend fun getRoom(roomId: RoomId): MatrixRoom? = withContext(sessionDispatcher) { // Check if already in memory... var cachedPairOfRoom = pairOfRoom(roomId) if (cachedPairOfRoom == null) { //... otherwise, lets wait for the SS to load all rooms and check again. - roomSummaryDataSource.awaitAllRoomsAreLoaded() + roomListService.allRooms().awaitLoaded() cachedPairOfRoom = pairOfRoom(roomId) } - if (cachedPairOfRoom == null) return null - val (roomListItem, fullRoom) = cachedPairOfRoom - return RustMatrixRoom( - sessionId = sessionId, - roomListItem = roomListItem, - innerRoom = fullRoom, - sessionCoroutineScope = sessionCoroutineScope, - coroutineDispatchers = dispatchers, - systemClock = clock, - roomContentForwarder = roomContentForwarder, - sessionData = sessionStore.getSession(sessionId.value)!!, - ) + cachedPairOfRoom?.let { (roomListItem, fullRoom) -> + RustMatrixRoom( + sessionId = sessionId, + roomListItem = roomListItem, + innerRoom = fullRoom, + sessionCoroutineScope = sessionCoroutineScope, + coroutineDispatchers = dispatchers, + systemClock = clock, + roomContentForwarder = roomContentForwarder, + sessionData = sessionStore.getSession(sessionId.value)!!, + ) + } } - private suspend fun pairOfRoom(roomId: RoomId): Pair ? = withContext(sessionDispatcher) { - val cachedRoomListItem = roomListService.roomOrNull(roomId.value) + private fun pairOfRoom(roomId: RoomId): Pair ? { + val cachedRoomListItem = innerRoomListService.roomOrNull(roomId.value) val fullRoom = cachedRoomListItem?.fullRoom() - if (cachedRoomListItem == null || fullRoom == null) { + return if (cachedRoomListItem == null || fullRoom == null) { Timber.d("No room cached for $roomId") null } else { @@ -224,10 +224,11 @@ class RustMatrixClient constructor( // Wait to receive the room back from the sync withTimeout(30_000L) { - roomSummaryDataSource.allRooms() + roomListService.allRooms().summaries .filter { roomSummaries -> roomSummaries.map { it.identifier() }.contains(roomId.value) - }.first() + } + .first() } roomId } @@ -271,7 +272,7 @@ class RustMatrixClient constructor( client.setDelegate(null) verificationService.destroy() syncService.destroy() - roomListService.destroy() + innerRoomListService.destroy() notificationClient.destroy() client.destroy() } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt new file mode 100644 index 0000000000..931133c266 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl + +import android.content.Context +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.Session +import org.matrix.rustcomponents.sdk.use +import java.io.File +import javax.inject.Inject + +class RustMatrixClientFactory @Inject constructor( + @ApplicationContext private val context: Context, + private val baseDirectory: File, + private val appCoroutineScope: CoroutineScope, + private val coroutineDispatchers: CoroutineDispatchers, + private val sessionStore: SessionStore, + private val userAgentProvider: UserAgentProvider, + private val clock: SystemClock, +) { + + suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) { + val client = ClientBuilder() + .basePath(baseDirectory.absolutePath) + .homeserverUrl(sessionData.homeserverUrl) + .username(sessionData.userId) + .userAgent(userAgentProvider.provide()) + // FIXME Quick and dirty fix for stopping version requests on startup https://github.com/matrix-org/matrix-rust-sdk/pull/1376 + .serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5")) + .use { it.build() } + + client.restoreSession(sessionData.toSession()) + + val syncService = client.syncService().finish() + + RustMatrixClient( + client = client, + syncService = syncService, + sessionStore = sessionStore, + appCoroutineScope = appCoroutineScope, + dispatchers = coroutineDispatchers, + baseDirectory = baseDirectory, + baseCacheDirectory = context.cacheDir, + clock = clock, + ) + } +} + +private fun SessionData.toSession() = Session( + accessToken = accessToken, + refreshToken = refreshToken, + userId = userId, + deviceId = deviceId, + homeserverUrl = homeserverUrl, + slidingSyncProxy = slidingSyncProxy, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 0bc299d020..ba06891013 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -16,33 +16,27 @@ package io.element.android.libraries.matrix.impl.auth -import android.content.Context +// TODO Oidc +// import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.impl.RustMatrixClient +import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.services.toolbox.api.systemclock.SystemClock -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.Client -import org.matrix.rustcomponents.sdk.ClientBuilder -// TODO Oidc -// import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl import org.matrix.rustcomponents.sdk.Session import org.matrix.rustcomponents.sdk.use import java.io.File @@ -53,13 +47,11 @@ import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthentication @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) class RustMatrixAuthenticationService @Inject constructor( - @ApplicationContext private val context: Context, - private val baseDirectory: File, - private val appCoroutineScope: CoroutineScope, + baseDirectory: File, private val coroutineDispatchers: CoroutineDispatchers, private val sessionStore: SessionStore, - private val clock: SystemClock, - private val userAgentProvider: UserAgentProvider, + userAgentProvider: UserAgentProvider, + private val rustMatrixClientFactory: RustMatrixClientFactory, ) : MatrixAuthenticationService { private val authService: RustAuthenticationService = RustAuthenticationService( @@ -84,16 +76,9 @@ class RustMatrixAuthenticationService @Inject constructor( runCatching { val sessionData = sessionStore.getSession(sessionId.value) if (sessionData != null) { - val client = ClientBuilder() - .basePath(baseDirectory.absolutePath) - .homeserverUrl(sessionData.homeserverUrl) - .username(sessionData.userId) - .userAgent(userAgentProvider.provide()) - .use { it.build() } - client.restoreSession(sessionData.toSession()) - createMatrixClient(client) + rustMatrixClientFactory.create(sessionData) } else { - throw IllegalStateException("No session to restore with id $sessionId") + error("No session to restore with id $sessionId") } }.mapFailure { failure -> failure.mapClientException() @@ -181,30 +166,8 @@ class RustMatrixAuthenticationService @Inject constructor( */ } - private suspend fun createMatrixClient(client: Client): MatrixClient { - val syncService = client.syncService().finish() - return RustMatrixClient( - client = client, - syncService = syncService, - sessionStore = sessionStore, - appCoroutineScope = appCoroutineScope, - dispatchers = coroutineDispatchers, - baseDirectory = baseDirectory, - baseCacheDirectory = context.cacheDir, - clock = clock, - ) - } } -private fun SessionData.toSession() = Session( - accessToken = accessToken, - refreshToken = refreshToken, - userId = userId, - deviceId = deviceId, - homeserverUrl = homeserverUrl, - slidingSyncProxy = slidingSyncProxy, -) - private fun Session.toSessionData() = SessionData( userId = userId, deviceId = deviceId, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index bf260be6ec..dba1dbd0a3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.verification.SessionVerificationService @Module @@ -40,8 +40,8 @@ object SessionMatrixModule { } @Provides - fun provideRoomSummaryDataSource(matrixClient: MatrixClient): RoomSummaryDataSource { - return matrixClient.roomSummaryDataSource + fun providesRoomListService(matrixClient: MatrixClient): RoomListService { + return matrixClient.roomListService } @Provides diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaUploadHandlerImpl.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaUploadHandlerImpl.kt new file mode 100644 index 0000000000..639f9149c4 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaUploadHandlerImpl.kt @@ -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.libraries.matrix.impl.media + +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.matrix.api.media.MediaUploadHandler +import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle +import java.io.File + +class MediaUploadHandlerImpl( + private val filesToUpload: List , + private val sendAttachmentJoinHandle: SendAttachmentJoinHandle, +) : MediaUploadHandler { + override suspend fun await(): Result = + runCatching { + sendAttachmentJoinHandle.join() + } + .also { cleanUpFiles() } + + override fun cancel() { + sendAttachmentJoinHandle.cancel() + cleanUpFiles() + } + + private fun cleanUpFiles() { + filesToUpload.forEach { file -> file.safeDelete() } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt index 07acb7fec5..0d9f794173 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt @@ -19,19 +19,29 @@ package io.element.android.libraries.matrix.impl.notification import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationData +import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.services.toolbox.api.systemclock.SystemClock +import org.matrix.rustcomponents.sdk.NotificationEvent import org.matrix.rustcomponents.sdk.NotificationItem import org.matrix.rustcomponents.sdk.use -class NotificationMapper { - private val timelineEventMapper = TimelineEventMapper() +class NotificationMapper( + sessionId: SessionId, + private val clock: SystemClock, +) { + private val notificationContentMapper = NotificationContentMapper(sessionId) - fun map(roomId: RoomId, notificationItem: NotificationItem): NotificationData { + fun map( + eventId: EventId, + roomId: RoomId, + notificationItem: NotificationItem + ): NotificationData { return notificationItem.use { item -> NotificationData( - senderId = UserId(item.event.senderId()), - eventId = EventId(item.event.eventId()), + eventId = eventId, roomId = roomId, senderAvatarUrl = item.senderInfo.avatarUrl, senderDisplayName = item.senderInfo.displayName, @@ -39,9 +49,28 @@ class NotificationMapper { roomDisplayName = item.roomInfo.displayName, isDirect = item.roomInfo.isDirect, isEncrypted = item.roomInfo.isEncrypted.orFalse(), - isNoisy = item.isNoisy, - event = item.event.use { event -> timelineEventMapper.map(event) } + isNoisy = item.isNoisy.orFalse(), + timestamp = item.timestamp() ?: clock.epochMillis(), + content = item.event.use { notificationContentMapper.map(it) }, + contentUrl = null, ) } } } + +class NotificationContentMapper(private val sessionId: SessionId) { + private val timelineEventToNotificationContentMapper = TimelineEventToNotificationContentMapper() + + fun map(notificationEvent: NotificationEvent): NotificationContent = + when (notificationEvent) { + is NotificationEvent.Timeline -> timelineEventToNotificationContentMapper.map(notificationEvent.event) + is NotificationEvent.Invite -> NotificationContent.StateEvent.RoomMemberContent( + userId = sessionId.value, + membershipState = RoomMembershipState.INVITE, + ) + } +} + +private fun NotificationItem.timestamp(): Long? { + return (this.event as? NotificationEvent.Timeline)?.event?.timestamp()?.toLong() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt index 92c996049e..0f3aebd049 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationService.kt @@ -16,29 +16,34 @@ package io.element.android.libraries.matrix.impl.notification +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationService +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.use class RustNotificationService( + sessionId: SessionId, private val notificationClient: NotificationClient, + private val dispatchers: CoroutineDispatchers, + clock: SystemClock, ) : NotificationService { - private val notificationMapper: NotificationMapper = NotificationMapper() + private val notificationMapper: NotificationMapper = NotificationMapper(sessionId, clock) - override fun getNotification( + override suspend fun getNotification( userId: SessionId, roomId: RoomId, eventId: EventId, - filterByPushRules: Boolean, - ): Result { - return runCatching { + ): Result = withContext(dispatchers.io) { + runCatching { val item = notificationClient.getNotification(roomId.value, eventId.value) item?.use { - notificationMapper.map(roomId, it) + notificationMapper.map(eventId, roomId, it) } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt similarity index 85% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt index f7d4a00188..e30e57113d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt @@ -16,8 +16,8 @@ package io.element.android.libraries.matrix.impl.notification +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.notification.NotificationContent -import io.element.android.libraries.matrix.api.notification.NotificationEvent import io.element.android.libraries.matrix.impl.room.RoomMemberMapper import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper import org.matrix.rustcomponents.sdk.MessageLikeEventContent @@ -27,22 +27,20 @@ import org.matrix.rustcomponents.sdk.TimelineEventType import org.matrix.rustcomponents.sdk.use import javax.inject.Inject -class TimelineEventMapper @Inject constructor() { +class TimelineEventToNotificationContentMapper @Inject constructor() { - fun map(timelineEvent: TimelineEvent): NotificationEvent { + fun map(timelineEvent: TimelineEvent): NotificationContent { return timelineEvent.use { - NotificationEvent( - timestamp = it.timestamp().toLong(), - content = it.eventType().toContent(), - contentUrl = null // TODO it.eventType().toContentUrl(), - ) + timelineEvent.eventType().use { eventType -> + eventType.toContent(senderId = UserId(timelineEvent.senderId())) + } } } } -private fun TimelineEventType.toContent(): NotificationContent { +private fun TimelineEventType.toContent(senderId: UserId): NotificationContent { return when (this) { - is TimelineEventType.MessageLike -> content.toContent() + is TimelineEventType.MessageLike -> content.toContent(senderId) is TimelineEventType.State -> content.toContent() } } @@ -75,9 +73,9 @@ private fun StateEventContent.toContent(): NotificationContent.StateEvent { } } -private fun MessageLikeEventContent.toContent(): NotificationContent.MessageLike { +private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationContent.MessageLike { return use { - when (it) { + when (this) { MessageLikeEventContent.CallAnswer -> NotificationContent.MessageLike.CallAnswer MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup @@ -89,10 +87,10 @@ private fun MessageLikeEventContent.toContent(): NotificationContent.MessageLike MessageLikeEventContent.KeyVerificationMac -> NotificationContent.MessageLike.KeyVerificationMac MessageLikeEventContent.KeyVerificationReady -> NotificationContent.MessageLike.KeyVerificationReady MessageLikeEventContent.KeyVerificationStart -> NotificationContent.MessageLike.KeyVerificationStart - is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(it.relatedEventId) + is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(relatedEventId) MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted is MessageLikeEventContent.RoomMessage -> { - NotificationContent.MessageLike.RoomMessage(EventMessageMapper().mapMessageType(it.messageType)) + NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType)) } MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction MessageLikeEventContent.Sticker -> NotificationContent.MessageLike.Sticker diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollAnswer.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollAnswer.kt new file mode 100644 index 0000000000..c3098bdcb0 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollAnswer.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.poll + +import io.element.android.libraries.matrix.api.poll.PollAnswer +import org.matrix.rustcomponents.sdk.PollAnswer as RustPollAnswer + +fun RustPollAnswer.map(): PollAnswer = PollAnswer( + id = id, + text = text, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollKind.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollKind.kt new file mode 100644 index 0000000000..bde49464ad --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/poll/PollKind.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.poll + +import io.element.android.libraries.matrix.api.poll.PollKind +import org.matrix.rustcomponents.sdk.PollKind as RustPollKind + +fun RustPollKind.map(): PollKind = when (this) { + RustPollKind.DISCLOSED -> PollKind.Disclosed + RustPollKind.UNDISCLOSED -> PollKind.Undisclosed +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt index 4e2d63d091..8ee0361ace 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.core.coroutine.parallelMap import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.ForwardEventException +import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withTimeout import org.matrix.rustcomponents.sdk.Room @@ -80,6 +81,6 @@ class RoomContentForwarder( } private object NoOpTimelineListener : TimelineListener { - override fun onUpdate(diff: TimelineDiff) = Unit + override fun onUpdate(diff: List ) = Unit } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 89e83b58c6..373880568f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl.room import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope +import io.element.android.libraries.core.coroutine.parallelMap import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomId @@ -27,6 +28,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState @@ -37,6 +39,7 @@ import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.impl.core.toProgressWatcher +import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline @@ -139,7 +142,7 @@ class RustMatrixRoom( override val avatarUrl: String? get() { - return innerRoom.avatarUrl() + return roomListItem.avatarUrl() ?: innerRoom.avatarUrl() } override val isEncrypted: Boolean @@ -168,7 +171,7 @@ class RustMatrixRoom( val currentMembers = currentState.roomMembers() _membersStateFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = currentMembers) runCatching { - innerRoom.members().map(RoomMemberMapper::map) + innerRoom.members().parallelMap(RoomMemberMapper::map) }.map { _membersStateFlow.value = MatrixRoomMembersState.Ready(it) }.onFailure { @@ -249,6 +252,12 @@ class RustMatrixRoom( } } + override suspend fun canUserRedact(userId: UserId): Result { + return runCatching { + innerRoom.canUserRedact(userId.value) + } + } + override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result { return runCatching { innerRoom.canUserSendState(userId.value, type.map()) @@ -261,26 +270,26 @@ class RustMatrixRoom( } } - override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result { - return sendAttachment { + override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback?): Result { + return sendAttachment(listOf(file, thumbnailFile)) { innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map(), progressCallback?.toProgressWatcher()) } } - override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result { - return sendAttachment { + override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result { + return sendAttachment(listOf(file, thumbnailFile)) { innerRoom.sendVideo(file.path, thumbnailFile.path, videoInfo.map(), progressCallback?.toProgressWatcher()) } } - override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result { - return sendAttachment { + override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result { + return sendAttachment(listOf(file)) { innerRoom.sendAudio(file.path, audioInfo.map(), progressCallback?.toProgressWatcher()) } } - override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result { - return sendAttachment { + override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result { + return sendAttachment(listOf(file)) { innerRoom.sendFile(file.path, fileInfo.map(), progressCallback?.toProgressWatcher()) } } @@ -363,13 +372,10 @@ class RustMatrixRoom( ) } } -} -//TODO handle cancellation, need refactoring of how we are catching errors -private suspend fun sendAttachment(handle: () -> SendAttachmentJoinHandle): Result { - return runCatching { - handle().use { - it.join() + private suspend fun sendAttachment(files: List , handle: () -> SendAttachmentJoinHandle): Result { + return runCatching { + MediaUploadHandlerImpl(files, handle()) } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt deleted file mode 100644 index efdbcf34ad..0000000000 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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.libraries.matrix.impl.room - -import io.element.android.libraries.matrix.api.room.RoomSummary -import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import org.matrix.rustcomponents.sdk.RoomList -import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate -import org.matrix.rustcomponents.sdk.RoomListException -import org.matrix.rustcomponents.sdk.RoomListInput -import org.matrix.rustcomponents.sdk.RoomListLoadingState -import org.matrix.rustcomponents.sdk.RoomListRange -import org.matrix.rustcomponents.sdk.RoomListService -import org.matrix.rustcomponents.sdk.RoomListServiceState -import timber.log.Timber - -internal class RustRoomSummaryDataSource( - private val roomListService: RoomListService, - private val sessionCoroutineScope: CoroutineScope, - dispatcher: CoroutineDispatcher, - roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), -) : RoomSummaryDataSource { - - private val allRooms = MutableStateFlow >(emptyList()) - private val inviteRooms = MutableStateFlow
>(emptyList()) - - private val allRoomsLoadingState: MutableStateFlow
= MutableStateFlow(RoomSummaryDataSource.LoadingState.NotLoaded) - private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, roomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = false) - private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, roomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = true) - - init { - sessionCoroutineScope.launch(dispatcher) { - val allRooms = roomListService.allRooms() - allRooms - .observeEntriesWithProcessor(allRoomsListProcessor) - .launchIn(this) - - allRooms - .loadingStateFlow() - .map { it.toRoomSummaryDataSourceLoadingState() } - .onEach { - allRoomsLoadingState.value = it - }.launchIn(this) - - launch { - // Wait until running, as invites is only available after that - roomListService.stateFlow().first { - it == RoomListServiceState.RUNNING - } - roomListService.invites() - .observeEntriesWithProcessor(inviteRoomsListProcessor) - .launchIn(this) - } - } - } - - override fun allRooms(): StateFlow > { - return allRooms - } - - override fun inviteRooms(): StateFlow
> { - return inviteRooms - } - - override fun allRoomsLoadingState(): StateFlow
{ - return allRoomsLoadingState - } - - override fun updateAllRoomsVisibleRange(range: IntRange) { - Timber.v("setVisibleRange=$range") - sessionCoroutineScope.launch { - try { - val ranges = listOf(RoomListRange(range.first.toUInt(), range.last.toUInt())) - roomListService.applyInput( - RoomListInput.Viewport(ranges) - ) - } catch (exception: RoomListException) { - Timber.e(exception, "Failed updating visible range") - } - } - } -} - -private fun RoomListLoadingState.toRoomSummaryDataSourceLoadingState(): RoomSummaryDataSource.LoadingState { - return when (this) { - is RoomListLoadingState.Loaded -> RoomSummaryDataSource.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0) - is RoomListLoadingState.NotLoaded -> RoomSummaryDataSource.LoadingState.NotLoaded - } -} - -private fun RoomList.observeEntriesWithProcessor(processor: RoomSummaryListProcessor): Flow { - return entriesFlow { roomListEntries -> - processor.postEntries(roomListEntries) - }.onEach { update -> - processor.postUpdate(update) - } -} - diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt similarity index 76% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomListExtensions.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt index 84c4eeaefb..8d96990a9e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomListExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt @@ -14,18 +14,19 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.impl.room +package io.element.android.libraries.matrix.impl.roomlist +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.catch import org.matrix.rustcomponents.sdk.RoomList import org.matrix.rustcomponents.sdk.RoomListEntriesListener import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListEntry -import org.matrix.rustcomponents.sdk.RoomListException import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.RoomListLoadingState import org.matrix.rustcomponents.sdk.RoomListLoadingStateListener @@ -42,31 +43,34 @@ fun RoomList.loadingStateFlow(): Flow = } } val result = loadingState(listener) - send(result.state) + try { + send(result.state) + } catch (exception: Exception) { + Timber.d("loadingStateFlow() initialState failed.") + } result.stateStream + }.catch { + Timber.d(it, "loadingStateFlow() failed") }.buffer(Channel.UNLIMITED) -fun RoomList.entriesFlow(onInitialList: suspend (List ) -> Unit): Flow = +fun RoomList.entriesFlow(onInitialList: suspend (List ) -> Unit): Flow > = mxCallbackFlow { val listener = object : RoomListEntriesListener { - override fun onUpdate(roomEntriesUpdate: RoomListEntriesUpdate) { + override fun onUpdate(roomEntriesUpdate: List
) { trySendBlocking(roomEntriesUpdate) } } val result = entries(listener) - onInitialList(result.entries) + try { + onInitialList(result.entries) + } catch (exception: Exception) { + Timber.d("entriesFlow() onInitialList failed.") + } result.entriesStream + }.catch { + Timber.d(it, "entriesFlow() failed") }.buffer(Channel.UNLIMITED) -fun RoomListService.roomOrNull(roomId: String): RoomListItem? { - return try { - room(roomId) - } catch (exception: RoomListException) { - Timber.d(exception, "Failed finding room with id=$roomId.") - return null - } -} - fun RoomListService.stateFlow(): Flow = mxCallbackFlow { val listener = object : RoomListServiceStateListener { @@ -74,5 +78,16 @@ fun RoomListService.stateFlow(): Flow = trySendBlocking(state) } } - state(listener) + tryOrNull { + state(listener) + } }.buffer(Channel.UNLIMITED) + +fun RoomListService.roomOrNull(roomId: String): RoomListItem? { + return try { + room(roomId) + } catch (exception: Exception) { + Timber.d(exception, "Failed finding room with id=$roomId.") + return null + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt similarity index 89% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt index 7dd7bf4581..b57eb892e0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryDetailsFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryDetailsFactory.kt @@ -14,10 +14,11 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.impl.room +package io.element.android.libraries.matrix.impl.roomlist import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails +import io.element.android.libraries.matrix.impl.room.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.RoomListItem diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryListProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt similarity index 93% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryListProcessor.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt index a8ab4cb807..9a67ff1f30 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSummaryListProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.impl.room +package io.element.android.libraries.matrix.impl.roomlist import io.element.android.libraries.core.coroutine.parallelMap -import io.element.android.libraries.matrix.api.room.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex @@ -50,12 +50,14 @@ class RoomSummaryListProcessor( initLatch.complete(Unit) } - suspend fun postUpdate(update: RoomListEntriesUpdate) { + suspend fun postUpdate(updates: List ) { // Makes sure to process first entries before update. initLatch.await() updateRoomSummaries { - Timber.v("Update rooms from postUpdate ($update) on ${Thread.currentThread()}") - applyUpdate(update) + Timber.v("Update rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}") + updates.forEach { update -> + applyUpdate(update) + } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomList.kt new file mode 100644 index 0000000000..481b38dd9b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomList.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.flow.StateFlow + +/** + * Simple implementation of [RoomList] where state flows are provided through constructor. + */ +class RustRoomList( + override val summaries: StateFlow >, + override val loadingState: StateFlow
+) : RoomList diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt new file mode 100644 index 0000000000..bf66bade3b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustRoomListService.kt @@ -0,0 +1,151 @@ +/* + * 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.libraries.matrix.impl.roomlist + +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate +import org.matrix.rustcomponents.sdk.RoomListException +import org.matrix.rustcomponents.sdk.RoomListInput +import org.matrix.rustcomponents.sdk.RoomListLoadingState +import org.matrix.rustcomponents.sdk.RoomListRange +import org.matrix.rustcomponents.sdk.RoomListServiceState +import timber.log.Timber +import org.matrix.rustcomponents.sdk.RoomListService as InnerRustRoomListService + +class RustRoomListService( + private val innerRoomListService: InnerRustRoomListService, + private val sessionCoroutineScope: CoroutineScope, + dispatcher: CoroutineDispatcher, + roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(), +) : RoomListService { + + private val allRooms = MutableStateFlow >(emptyList()) + private val inviteRooms = MutableStateFlow
>(emptyList()) + + private val allRoomsLoadingState: MutableStateFlow
= MutableStateFlow(RoomList.LoadingState.NotLoaded) + private val allRoomsListProcessor = RoomSummaryListProcessor(allRooms, innerRoomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = false) + private val invitesLoadingState: MutableStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded) + private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, innerRoomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = true) + + init { + sessionCoroutineScope.launch(dispatcher) { + val allRooms = innerRoomListService.allRooms() + allRooms + .observeEntriesWithProcessor(allRoomsListProcessor) + .launchIn(this) + allRooms + .observeLoadingState(allRoomsLoadingState) + .launchIn(this) + + + launch { + // Wait until running, as invites is only available after that + innerRoomListService.stateFlow().first { + it == RoomListServiceState.RUNNING + } + val invites = innerRoomListService.invites() + invites + .observeEntriesWithProcessor(inviteRoomsListProcessor) + .launchIn(this) + invites + .observeLoadingState(invitesLoadingState) + .launchIn(this) + + } + } + } + + override fun allRooms(): RoomList { + return RustRoomList(allRooms, allRoomsLoadingState) + } + + override fun invites(): RoomList { + return RustRoomList(inviteRooms, invitesLoadingState) + } + + override fun updateAllRoomsVisibleRange(range: IntRange) { + Timber.v("setVisibleRange=$range") + sessionCoroutineScope.launch { + try { + val ranges = listOf(RoomListRange(range.first.toUInt(), range.last.toUInt())) + innerRoomListService.applyInput( + RoomListInput.Viewport(ranges) + ) + } catch (exception: RoomListException) { + Timber.e(exception, "Failed updating visible range") + } + } + } + + override val state: StateFlow = + innerRoomListService.stateFlow() + .map { it.toRoomListState() } + .onEach { state -> + Timber.d("RoomList state=$state") + } + .distinctUntilChanged() + .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RoomListService.State.Idle) +} + +private fun RoomListLoadingState.toLoadingState(): RoomList.LoadingState { + return when (this) { + is RoomListLoadingState.Loaded -> RoomList.LoadingState.Loaded(maximumNumberOfRooms?.toInt() ?: 0) + RoomListLoadingState.NotLoaded -> RoomList.LoadingState.NotLoaded + } +} + +private fun RoomListServiceState.toRoomListState(): RoomListService.State { + return when (this) { + RoomListServiceState.INIT, + RoomListServiceState.SETTING_UP -> RoomListService.State.Idle + RoomListServiceState.RUNNING -> RoomListService.State.Running + RoomListServiceState.ERROR -> RoomListService.State.Error + RoomListServiceState.TERMINATED -> RoomListService.State.Terminated + } +} + +private fun org.matrix.rustcomponents.sdk.RoomList.observeEntriesWithProcessor(processor: RoomSummaryListProcessor): Flow > { + return entriesFlow { roomListEntries -> + processor.postEntries(roomListEntries) + }.onEach { update -> + processor.postUpdate(update) + } +} + +private fun org.matrix.rustcomponents.sdk.RoomList.observeLoadingState(stateFlow: MutableStateFlow
): Flow { + return loadingStateFlow() + .map { it.toLoadingState() } + .onEach { + stateFlow.value = it + } +} + diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapper.kt index 51228231f9..ae90f9fc44 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/AppStateMapper.kt @@ -17,19 +17,8 @@ package io.element.android.libraries.matrix.impl.sync import io.element.android.libraries.matrix.api.sync.SyncState -import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.SyncServiceState -internal fun RoomListServiceState.toSyncState(): SyncState { - return when (this) { - RoomListServiceState.INIT, - RoomListServiceState.SETTING_UP -> SyncState.Idle - RoomListServiceState.RUNNING -> SyncState.Running - RoomListServiceState.ERROR -> SyncState.Error - RoomListServiceState.TERMINATED -> SyncState.Terminated - } -} - internal fun SyncServiceState.toSyncState(): SyncState { return when (this) { SyncServiceState.IDLE -> SyncState.Idle diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt index ca40ca400c..932da42afb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt @@ -19,38 +19,40 @@ package io.element.android.libraries.matrix.impl.sync import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.SyncServiceInterface +import org.matrix.rustcomponents.sdk.SyncServiceState import timber.log.Timber class RustSyncService( private val innerSyncService: SyncServiceInterface, - roomListStateFlow: Flow , sessionCoroutineScope: CoroutineScope ) : SyncService { override suspend fun startSync() = runCatching { - Timber.v("Start sync") + Timber.i("Start sync") innerSyncService.start() + }.onFailure { + Timber.d("Start sync failed: $it") } - override fun stopSync() = runCatching { - Timber.v("Stop sync") - innerSyncService.pause() + override suspend fun stopSync() = runCatching { + Timber.i("Stop sync") + innerSyncService.stop() + }.onFailure { + Timber.d("Stop sync failed: $it") } override val syncState: StateFlow = - roomListStateFlow - .map(RoomListServiceState::toSyncState) + innerSyncService.stateFlow() + .map(SyncServiceState::toSyncState) .onEach { state -> - Timber.v("Sync state=$state") + Timber.i("Sync state=$state") } .distinctUntilChanged() .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SyncServiceExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SyncServiceExtension.kt index 36dabb71f3..c9e38ec7d4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SyncServiceExtension.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/SyncServiceExtension.kt @@ -16,21 +16,24 @@ package io.element.android.libraries.matrix.impl.sync +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer -import org.matrix.rustcomponents.sdk.SyncService +import org.matrix.rustcomponents.sdk.SyncServiceInterface import org.matrix.rustcomponents.sdk.SyncServiceState import org.matrix.rustcomponents.sdk.SyncServiceStateObserver -fun SyncService.stateFlow(): Flow = +fun SyncServiceInterface.stateFlow(): Flow = mxCallbackFlow { val listener = object : SyncServiceStateObserver { override fun onUpdate(state: SyncServiceState) { trySendBlocking(state) } } - state(listener) + tryOrNull { + state(listener) + } }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt index b14d459697..fd880e7fe3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessor.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.sync.withLock import org.matrix.rustcomponents.sdk.TimelineChange import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineItem +import timber.log.Timber internal class MatrixTimelineDiffProcessor( private val timelineItems: MutableStateFlow >, @@ -33,14 +34,18 @@ internal class MatrixTimelineDiffProcessor( suspend fun postItems(items: List
) { updateTimelineItems { + Timber.v("Update timeline items from postItems (with ${items.size} items) on ${Thread.currentThread()}") val mappedItems = items.map { it.asMatrixTimelineItem() } addAll(0, mappedItems) } } - suspend fun postDiff(diff: TimelineDiff) { + suspend fun postDiffs(diffs: List ) { updateTimelineItems { - applyDiff(diff) + Timber.v("Update timeline items from postDiffs (with ${diffs.size} items) on ${Thread.currentThread()}") + diffs.forEach { diff -> + applyDiff(diff) + } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt index d6febb32dc..bddd2bc872 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt @@ -16,28 +16,47 @@ package io.element.android.libraries.matrix.impl.timeline +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import io.element.android.libraries.matrix.impl.util.destroyAll import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch import org.matrix.rustcomponents.sdk.BackPaginationStatus import org.matrix.rustcomponents.sdk.BackPaginationStatusListener import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.TimelineDiff import org.matrix.rustcomponents.sdk.TimelineItem import org.matrix.rustcomponents.sdk.TimelineListener +import timber.log.Timber -internal fun Room.timelineDiffFlow(onInitialList: suspend (List ) -> Unit): Flow = - mxCallbackFlow { +internal fun Room.timelineDiffFlow(onInitialList: suspend (List ) -> Unit): Flow > = + callbackFlow { val listener = object : TimelineListener { - override fun onUpdate(diff: TimelineDiff) { + override fun onUpdate(diff: List
) { trySendBlocking(diff) } } + val roomId = id() + Timber.d("Open timelineDiffFlow for room $roomId") val result = addTimelineListener(listener) - onInitialList(result.items) - result.itemsStream + try { + onInitialList(result.items) + } catch (exception: Exception) { + Timber.d(exception, "Catch failure in timelineDiffFlow of room $roomId") + } + awaitClose { + Timber.d("Close timelineDiffFlow for room $roomId") + result.itemsStream.cancelAndDestroy() + result.items.destroyAll() + } + }.catch { + Timber.d(it, "timelineDiffFlow() failed") }.buffer(Channel.UNLIMITED) internal fun Room.backPaginationStatusFlow(): Flow = @@ -47,5 +66,7 @@ internal fun Room.backPaginationStatusFlow(): Flow = trySendBlocking(status) } } - subscribeToBackPaginationStatus(listener) + tryOrNull { + subscribeToBackPaginationStatus(listener) + } }.buffer(Channel.UNLIMITED) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index d9b6604170..aa8427608d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -27,11 +27,13 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelin import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor +import io.element.android.libraries.matrix.impl.util.TaskHandleBag import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -40,7 +42,6 @@ import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.BackPaginationStatus @@ -78,6 +79,7 @@ class RustMatrixTimeline( lastLoginTimestamp = lastLoginTimestamp, isRoomEncrypted = matrixRoom.isEncrypted, paginationStateFlow = _paginationState, + dispatcher = dispatcher, ) private val timelineItemFactory = MatrixTimelineItemMapper( @@ -100,49 +102,55 @@ class RustMatrixTimeline( init { Timber.d("Initialize timeline for room ${matrixRoom.roomId}") + + val taskHandleBag = TaskHandleBag() roomCoroutineScope.launch(dispatcher) { innerRoom.timelineDiffFlow { initialList -> postItems(initialList) - }.onEach { diff -> - if (diff.eventOrigin() == EventItemOrigin.SYNC) { + }.onEach { diffs -> + if (diffs.any { diff -> diff.eventOrigin() == EventItemOrigin.SYNC }) { onNewSyncedEvent() } - postDiff(diff) + postDiffs(diffs) }.launchIn(this) innerRoom.backPaginationStatusFlow() .onEach { postPaginationStatus(it) - }.launchIn(this) + } + .launchIn(this) - fetchMembers() + taskHandleBag += fetchMembers().getOrNull() + }.invokeOnCompletion { + taskHandleBag.dispose() } } private suspend fun fetchMembers() = withContext(dispatcher) { + initLatch.await() runCatching { innerRoom.fetchMembers() } } - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - override val timelineItems: Flow > = _timelineItems.sample(50) - .mapLatest { items -> - encryptedHistoryPostProcessor.process(items) - } + @OptIn(ExperimentalCoroutinesApi::class) + override val timelineItems: Flow
> = _timelineItems.mapLatest { items -> + encryptedHistoryPostProcessor.process(items) + } - private suspend fun postItems(items: List
) { + private suspend fun postItems(items: List ) = coroutineScope { // Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap. items.chunked(INITIAL_MAX_SIZE).reversed().forEach { + ensureActive() timelineDiffProcessor.postItems(it) } isInit.set(true) initLatch.complete(Unit) } - private suspend fun postDiff(timelineDiff: TimelineDiff) { + private suspend fun postDiffs(diffs: List ) { initLatch.await() - timelineDiffProcessor.postDiff(timelineDiff) + timelineDiffProcessor.postDiffs(diffs) } private fun postPaginationStatus(status: BackPaginationStatus) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index 96b61fd558..330a06da62 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -42,6 +42,8 @@ import org.matrix.rustcomponents.sdk.MessageType as RustMessageType class EventMessageMapper { + private val timelineEventContentMapper by lazy { TimelineEventContentMapper() } + fun map(message: Message): MessageContent = message.use { val type = it.msgtype().use(this::mapMessageType) val inReplyToId = it.inReplyTo()?.eventId?.let(::EventId) @@ -51,7 +53,7 @@ class EventMessageMapper { val senderProfile = details.senderProfile as? ProfileDetails.Ready InReplyTo.Ready( eventId = inReplyToId!!, - content = map(details.message), + content = timelineEventContentMapper.map(details.content), senderId = UserId(details.sender), senderDisplayName = senderProfile?.displayName, senderAvatarUrl = senderProfile?.avatarUrl, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index 359b9ecdef..21e7d51638 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails +import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import org.matrix.rustcomponents.sdk.Reaction import org.matrix.rustcomponents.sdk.EventItemOrigin as RustEventItemOrigin import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState @@ -81,8 +82,12 @@ private fun List ?.map(): List { return this?.map { EventReaction( key = it.key, - count = it.count.toLong(), - senderIds = it.senders.map { sender -> UserId(sender.senderId) } + senders = it.senders.map { sender -> + ReactionSender( + senderId = UserId(sender.senderId), + timestamp = sender.timestamp.toLong() + ) + } ) } ?: emptyList() } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 33727c15d4..7ee1d1490d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -22,6 +22,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +import io.element.android.libraries.matrix.api.timeline.item.event.PollEndContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent @@ -30,6 +32,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerConten import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.impl.media.map +import io.element.android.libraries.matrix.impl.poll.map import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.TimelineItemContentKind import org.matrix.rustcomponents.sdk.EncryptedMessage as RustEncryptedMessage @@ -91,11 +94,27 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap url = kind.url, ) } + is TimelineItemContentKind.Poll -> { + PollContent( + question = kind.question, + kind = kind.kind.map(), + maxSelections = kind.maxSelections, + answers = kind.answers.map { answer -> answer.map() }, + votes = kind.votes.mapValues { vote -> + vote.value.map { userId -> UserId(userId) } + }, + endTime = kind.endTime, + ) + } + is TimelineItemContentKind.PollEnd -> { + PollEndContent(startEventId = kind.startEventId) + } is TimelineItemContentKind.UnableToDecrypt -> { UnableToDecryptContent( data = kind.msg.map() ) } + else -> UnknownContent } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt index 0fe12e6391..b273bef21b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt @@ -19,18 +19,23 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.withContext +import timber.log.Timber import java.util.Date class TimelineEncryptedHistoryPostProcessor( + private val dispatcher: CoroutineDispatcher, private val lastLoginTimestamp: Date?, private val isRoomEncrypted: Boolean, private val paginationStateFlow: MutableStateFlow , ) { - fun process(items: List ): List { - if (!isRoomEncrypted || lastLoginTimestamp == null) return items + suspend fun process(items: List ): List = withContext(dispatcher) { + Timber.d("Process on Thread=${Thread.currentThread()}") + if (!isRoomEncrypted || lastLoginTimestamp == null) return@withContext items val filteredItems = replaceWithEncryptionHistoryBannerIfNeeded(items) // Disable back pagination @@ -43,7 +48,7 @@ class TimelineEncryptedHistoryPostProcessor( ) } } - return filteredItems + filteredItems } private fun replaceWithEncryptionHistoryBannerIfNeeded(list: List ): List { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/LogEventLocation.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/LogEventLocation.kt new file mode 100644 index 0000000000..712735649c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/LogEventLocation.kt @@ -0,0 +1,40 @@ +/* + * 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.libraries.matrix.impl.tracing + +/** + * This class is used to provide file, line, column information to the Rust SDK [org.matrix.rustcomponents.sdk.logEvent] method. + * The data is extracted from a [StackTraceElement] instance. + */ +data class LogEventLocation( + val file: String, + val line: UInt?, +) { + + companion object { + /** + * Create a [LogEventLocation] from a [StackTraceElement]. + */ + fun from(stackTraceElement: StackTraceElement): LogEventLocation { + return LogEventLocation( + file = stackTraceElement.fileName ?: "", + line = stackTraceElement.lineNumber.takeIf { it >= 0 }?.toUInt() + ) + } + } +} + diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt new file mode 100644 index 0000000000..c211f48c05 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt @@ -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.libraries.matrix.impl.tracing + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.tracing.TracingConfiguration +import io.element.android.libraries.matrix.api.tracing.TracingService +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration +import org.matrix.rustcomponents.sdk.TracingFileConfiguration +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class RustTracingService @Inject constructor(private val buildMeta: BuildMeta) : TracingService { + + override fun setupTracing(tracingConfiguration: TracingConfiguration) { + val filter = tracingConfiguration.filterConfiguration + val rustTracingConfiguration = org.matrix.rustcomponents.sdk.TracingConfiguration( + filter = tracingConfiguration.filterConfiguration.filter, + writeToStdoutOrSystem = tracingConfiguration.writesToLogcat, + writeToFiles = when (val writeToFilesConfiguration = tracingConfiguration.writesToFilesConfiguration) { + is WriteToFilesConfiguration.Disabled -> null + is WriteToFilesConfiguration.Enabled -> TracingFileConfiguration( + path = writeToFilesConfiguration.directory, + filePrefix = writeToFilesConfiguration.filenamePrefix, + ) + }, + ) + org.matrix.rustcomponents.sdk.setupTracing(rustTracingConfiguration) + Timber.v("Tracing config filter = $filter") + } + + override fun createTimberTree(): Timber.Tree { + return RustTracingTree(retrieveFromStackTrace = buildMeta.isDebuggable) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt new file mode 100644 index 0000000000..275994081d --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingTree.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.tracing + +import android.util.Log +import io.element.android.libraries.matrix.api.tracing.Target +import org.matrix.rustcomponents.sdk.LogLevel +import org.matrix.rustcomponents.sdk.logEvent +import timber.log.Timber + +/** + * List of fully qualified class names to ignore when looking for the first stack trace element. + */ +private val fqcnIgnore = listOf( + Timber::class.java.name, + Timber.Forest::class.java.name, + Timber.Tree::class.java.name, + RustTracingTree::class.java.name, +) + +/** + * A Timber tree that passes logs to the Rust SDK. + */ +internal class RustTracingTree(private val retrieveFromStackTrace: Boolean) : Timber.Tree() { + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + val location = if (retrieveFromStackTrace) { + getLogEventLocationFromStackTrace() + } else { + LogEventLocation("", null) + } + val logLevel = priority.toLogLevel() + logEvent( + file = location.file, + line = location.line, + level = logLevel, + target = Target.ELEMENT.filter, + message = message, + ) + } + + /** + * Extract the [LogEventLocation] from the stack trace. + */ + private fun getLogEventLocationFromStackTrace(): LogEventLocation { + return Throwable(null, null).stackTrace + .first { it.className !in fqcnIgnore } + .let(LogEventLocation::from) + } +} + +/** + * Convert a log priority to a Rust SDK log level. + */ +private fun Int.toLogLevel(): LogLevel { + return when (this) { + Log.VERBOSE -> LogLevel.TRACE + Log.DEBUG -> LogLevel.DEBUG + Log.INFO -> LogLevel.INFO + Log.WARN -> LogLevel.WARN + Log.ERROR -> LogLevel.ERROR + else -> LogLevel.DEBUG + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt index a347973e89..fbf393e587 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/CallbackFlow.kt @@ -21,11 +21,10 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import org.matrix.rustcomponents.sdk.TaskHandle -internal fun mxCallbackFlow(block: suspend ProducerScope .() -> TaskHandle) = +internal fun mxCallbackFlow(block: suspend ProducerScope .() -> TaskHandle?) = callbackFlow { - val token: TaskHandle = block(this) + val taskHandle: TaskHandle? = block(this) awaitClose { - token.cancel() - token.destroy() + taskHandle?.cancelAndDestroy() } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt new file mode 100644 index 0000000000..ac92a2e026 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Disposables.kt @@ -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.libraries.matrix.impl.util + +import org.matrix.rustcomponents.sdk.Disposable + +/** + * Call destroy on all elements of the iterable. + */ +internal fun Iterable .destroyAll() = forEach { it.destroy() } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandleBag.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandle.kt similarity index 73% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandleBag.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandle.kt index 9a21645351..5842ba1546 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandleBag.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/TaskHandle.kt @@ -19,18 +19,22 @@ package io.element.android.libraries.matrix.impl.util import org.matrix.rustcomponents.sdk.TaskHandle import java.util.concurrent.CopyOnWriteArraySet -class TaskHandleBag(private val tokens: MutableSet = CopyOnWriteArraySet()) : Set by tokens { +fun TaskHandle.cancelAndDestroy() { + cancel() + destroy() +} + +class TaskHandleBag(private val taskHandles: MutableSet = CopyOnWriteArraySet()) : Set by taskHandles { operator fun plusAssign(taskHandle: TaskHandle?) { if (taskHandle == null) return - tokens += taskHandle + taskHandles += taskHandle } fun dispose() { - tokens.forEach { - it.cancel() - it.destroy() + taskHandles.forEach { + it.cancelAndDestroy() } - tokens.clear() + taskHandles.clear() } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt index 91f0bc1883..63920cd6ce 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt @@ -22,15 +22,18 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.room.anEventTimelineItem import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import org.junit.Test import java.util.Date class TimelineEncryptedHistoryPostProcessorTest { - private val defaultLastLoginTimestamp = Date(1689061264L) + private val defaultLastLoginTimestamp = Date(1_689_061_264L) @Test - fun `given an unencrypted room, nothing is done`() { + fun `given an unencrypted room, nothing is done`() = runTest { val processor = createPostProcessor(isRoomEncrypted = false) val items = listOf( MatrixTimelineItem.Event(0L, anEventTimelineItem()) @@ -39,7 +42,7 @@ class TimelineEncryptedHistoryPostProcessorTest { } @Test - fun `given a null lastLoginTimestamp, nothing is done`() { + fun `given a null lastLoginTimestamp, nothing is done`() = runTest { val processor = createPostProcessor(lastLoginTimestamp = null) val items = listOf( MatrixTimelineItem.Event(0L, anEventTimelineItem()) @@ -48,14 +51,14 @@ class TimelineEncryptedHistoryPostProcessorTest { } @Test - fun `given an empty list, nothing is done`() { + fun `given an empty list, nothing is done`() = runTest { val processor = createPostProcessor() val items = emptyList () assertThat(processor.process(items)).isSameInstanceAs(items) } @Test - fun `given a list with no items before lastLoginTimestamp, nothing is done`() { + fun `given a list with no items before lastLoginTimestamp, nothing is done`() = runTest { val processor = createPostProcessor() val items = listOf( MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)) @@ -64,7 +67,7 @@ class TimelineEncryptedHistoryPostProcessorTest { } @Test - fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() { + fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() = runTest { val processor = createPostProcessor() val items = listOf( MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)) @@ -74,7 +77,7 @@ class TimelineEncryptedHistoryPostProcessorTest { } @Test - fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() { + fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() = runTest { val processor = createPostProcessor() val items = listOf( MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)) @@ -85,7 +88,7 @@ class TimelineEncryptedHistoryPostProcessorTest { } @Test - fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, they're replaced and the user can't back paginate`() { + fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, they're replaced and the user can't back paginate`() = runTest { val paginationStateFlow = MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)) val processor = createPostProcessor(paginationStateFlow = paginationStateFlow) val items = listOf( @@ -102,7 +105,7 @@ class TimelineEncryptedHistoryPostProcessorTest { assertThat(paginationStateFlow.value).isEqualTo(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = false, isBackPaginating = false)) } - private fun createPostProcessor( + private fun TestScope.createPostProcessor( lastLoginTimestamp: Date? = defaultLastLoginTimestamp, isRoomEncrypted: Boolean = true, paginationStateFlow: MutableStateFlow = @@ -111,5 +114,6 @@ class TimelineEncryptedHistoryPostProcessorTest { lastLoginTimestamp = lastLoginTimestamp, isRoomEncrypted = isRoomEncrypted, paginationStateFlow = paginationStateFlow, + dispatcher = StandardTestDispatcher(testScheduler) ) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 1a654ac8d4..1229836e30 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -27,7 +27,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService @@ -35,7 +35,7 @@ import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.room.FakeMatrixRoom -import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.tests.testutils.simulateLongTask @@ -45,7 +45,7 @@ class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, private val userDisplayName: Result = Result.success(A_USER_NAME), private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), - override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), + override val roomListService: RoomListService = FakeRoomListService(), override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val pushersService: FakePushersService = FakePushersService(), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaUploadHandler.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaUploadHandler.kt new file mode 100644 index 0000000000..100dbd5f66 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaUploadHandler.kt @@ -0,0 +1,31 @@ +/* + * 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.libraries.matrix.test.media + +import io.element.android.libraries.matrix.api.media.MediaUploadHandler +import io.element.android.tests.testutils.simulateLongTask +import kotlin.coroutines.cancellation.CancellationException + +class FakeMediaUploadHandler( + private var result: Result = Result.success(Unit), +) : MediaUploadHandler { + override suspend fun await(): Result = simulateLongTask { result } + + override fun cancel() { + result = Result.failure(CancellationException()) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt index 9eb5a20ba4..7cb92d35c5 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/FakeNotificationService.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.notification.NotificationService class FakeNotificationService : NotificationService { - override fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId, filterByPushRules: Boolean): Result { + override suspend fun getNotification(userId: SessionId, roomId: RoomId, eventId: EventId): Result { return Result.success(null) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 59f6ed57bd..a660c56a99 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState @@ -34,6 +35,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.delay @@ -56,6 +58,7 @@ class FakeMatrixRoom( override val joinedMemberCount: Long = 123L, override val activeMemberCount: Long = 234L, private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), + canRedact: Boolean = false, ) : MatrixRoom { private var ignoreResult: Result = Result.success(Unit) @@ -66,9 +69,10 @@ class FakeMatrixRoom( private var joinRoomResult = Result.success(Unit) private var inviteUserResult = Result.success(Unit) private var canInviteResult = Result.success(true) + private var canRedactResult = Result.success(canRedact) private val canSendStateResults = mutableMapOf >() private val canSendEventResults = mutableMapOf >() - private var sendMediaResult = Result.success(Unit) + private var sendMediaResult = Result.success(FakeMediaUploadHandler()) private var setNameResult = Result.success(Unit) private var setTopicResult = Result.success(Unit) private var updateAvatarResult = Result.success(Unit) @@ -207,6 +211,10 @@ class FakeMatrixRoom( return canInviteResult } + override suspend fun canUserRedact(userId: UserId): Result { + return canRedactResult + } + override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result { return canSendStateResults[type] ?: Result.failure(IllegalStateException("No fake answer")) } @@ -220,21 +228,34 @@ class FakeMatrixRoom( thumbnailFile: File, imageInfo: ImageInfo, progressCallback: ProgressCallback? - ): Result = fakeSendMedia(progressCallback) + ): Result = fakeSendMedia(progressCallback) - override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo, progressCallback: ProgressCallback?): Result = fakeSendMedia( + override suspend fun sendVideo( + file: File, + thumbnailFile: File, + videoInfo: VideoInfo, + progressCallback: ProgressCallback? + ): Result = fakeSendMedia( progressCallback ) - override suspend fun sendAudio(file: File, audioInfo: AudioInfo, progressCallback: ProgressCallback?): Result = fakeSendMedia(progressCallback) + override suspend fun sendAudio( + file: File, + audioInfo: AudioInfo, + progressCallback: ProgressCallback? + ): Result = fakeSendMedia(progressCallback) - override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result = fakeSendMedia(progressCallback) + override suspend fun sendFile( + file: File, + fileInfo: FileInfo, + progressCallback: ProgressCallback? + ): Result = fakeSendMedia(progressCallback) override suspend fun forwardEvent(eventId: EventId, roomIds: List ): Result = simulateLongTask { forwardEventResult } - private suspend fun fakeSendMedia(progressCallback: ProgressCallback?): Result = simulateLongTask { + private suspend fun fakeSendMedia(progressCallback: ProgressCallback?): Result = simulateLongTask { sendMediaResult.onSuccess { progressCallbackValues.forEach { (current, total) -> progressCallback?.onProgress(current, total) @@ -332,7 +353,7 @@ class FakeMatrixRoom( unignoreResult = result } - fun givenSendMediaResult(result: Result ) { + fun givenSendMediaResult(result: Result ) { sendMediaResult = result } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 4df815c54c..59bac7ad40 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -20,8 +20,8 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.room.RoomSummary -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.api.room.message.RoomMessage import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventContent diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomSummaryDataSource.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt similarity index 51% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomSummaryDataSource.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt index cae36e14c8..fa2e347e3b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomSummaryDataSource.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/FakeRoomListService.kt @@ -14,18 +14,21 @@ * limitations under the License. */ -package io.element.android.libraries.matrix.test.room +package io.element.android.libraries.matrix.test.roomlist -import io.element.android.libraries.matrix.api.room.RoomSummary -import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class FakeRoomSummaryDataSource : RoomSummaryDataSource { +class FakeRoomListService : RoomListService { private val allRoomSummariesFlow = MutableStateFlow >(emptyList()) private val inviteRoomSummariesFlow = MutableStateFlow
>(emptyList()) - private val allRoomsLoadingStateFlow = MutableStateFlow
(RoomSummaryDataSource.LoadingState.NotLoaded) + private val allRoomsLoadingStateFlow = MutableStateFlow (RoomList.LoadingState.NotLoaded) + private val inviteRoomsLoadingStateFlow = MutableStateFlow (RoomList.LoadingState.NotLoaded) + private val roomListStateFlow = MutableStateFlow (RoomListService.State.Idle) suspend fun postAllRooms(roomSummaries: List ) { allRoomSummariesFlow.emit(roomSummaries) @@ -35,20 +38,16 @@ class FakeRoomSummaryDataSource : RoomSummaryDataSource { inviteRoomSummariesFlow.emit(roomSummaries) } - suspend fun postLoadingState(loadingState: RoomSummaryDataSource.LoadingState) { + suspend fun postAllRoomsLoadingState(loadingState: RoomList.LoadingState) { allRoomsLoadingStateFlow.emit(loadingState) } - override fun allRoomsLoadingState(): StateFlow { - return allRoomsLoadingStateFlow + suspend fun postInviteRoomsLoadingState(loadingState: RoomList.LoadingState) { + inviteRoomsLoadingStateFlow.emit(loadingState) } - override fun allRooms(): StateFlow > { - return allRoomSummariesFlow - } - - override fun inviteRooms(): StateFlow
> { - return inviteRoomSummariesFlow + suspend fun postState(state: RoomListService.State) { + roomListStateFlow.emit(state) } var latestSlidingSyncRange: IntRange? = null @@ -57,4 +56,20 @@ class FakeRoomSummaryDataSource : RoomSummaryDataSource { override fun updateAllRoomsVisibleRange(range: IntRange) { latestSlidingSyncRange = range } + + override fun allRooms(): RoomList { + return SimpleRoomList( + summaries = allRoomSummariesFlow, + loadingState = allRoomsLoadingStateFlow + ) + } + + override fun invites(): RoomList { + return SimpleRoomList( + summaries = inviteRoomSummariesFlow, + loadingState = inviteRoomsLoadingStateFlow + ) + } + + override val state: StateFlow
= roomListStateFlow } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimpleRoomList.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimpleRoomList.kt new file mode 100644 index 0000000000..28b04ae318 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/roomlist/SimpleRoomList.kt @@ -0,0 +1,26 @@ +/* + * 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.libraries.matrix.test.roomlist + +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import kotlinx.coroutines.flow.StateFlow + +data class SimpleRoomList( + override val summaries: StateFlow >, + override val loadingState: StateFlow
+) : RoomList diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt index dd653a76ec..4e618deb9a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt @@ -34,7 +34,7 @@ class FakeSyncService : SyncService { return Result.success(Unit) } - override fun stopSync(): Result { + override suspend fun stopSync(): Result { syncStateFlow.value = SyncState.Terminated return Result.success(Unit) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt index b12f577c36..3b276903a0 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt @@ -15,6 +15,7 @@ */ @file:OptIn(ExperimentalMaterialApi::class) +@file:Suppress("UsingMaterialAndMaterial3Libraries") package io.element.android.libraries.matrix.ui.components @@ -32,6 +33,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -111,12 +113,12 @@ private fun AvatarActionBottomSheetContent( @Preview @Composable -fun AvatarActionBottomSheetLightPreview() = +internal fun AvatarActionBottomSheetLightPreview() = ElementPreviewLight { ContentToPreview() } @Preview @Composable -fun AvatarActionBottomSheetDarkPreview() = +internal fun AvatarActionBottomSheetDarkPreview() = ElementPreviewDark { ContentToPreview() } @Composable @@ -124,7 +126,8 @@ private fun ContentToPreview() { AvatarActionBottomSheet( actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove), modalBottomSheetState = ModalBottomSheetState( - initialValue = ModalBottomSheetValue.Expanded + initialValue = ModalBottomSheetValue.Expanded, + density = LocalDensity.current, ), ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt index 6054aa53af..a42d44b642 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt @@ -103,12 +103,12 @@ private fun MatrixUserHeaderContent( @Preview @Composable -fun MatrixUserHeaderLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = +internal fun MatrixUserHeaderLightPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreviewLight { ContentToPreview(matrixUser) } @Preview @Composable -fun MatrixUserHeaderDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = +internal fun MatrixUserHeaderDarkPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreviewDark { ContentToPreview(matrixUser) } @Composable diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt index 57901edc03..faa80f82e6 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeaderPlaceholder.kt @@ -68,12 +68,12 @@ fun MatrixUserHeaderPlaceholder( @Preview @Composable -fun MatrixUserHeaderPlaceholderLightPreview() = +internal fun MatrixUserHeaderPlaceholderLightPreview() = ElementPreviewLight { ContentToPreview() } @Preview @Composable -fun MatrixUserHeaderPlaceholderDarkPreview() = +internal fun MatrixUserHeaderPlaceholderDarkPreview() = ElementPreviewDark { ContentToPreview() } @Composable diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt index 2881235335..da6f69d830 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt index de4d575de0..b84cddd531 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt @@ -41,7 +41,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.theme.components.Checkbox -import io.element.android.libraries.designsystem.theme.components.Divider +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -149,11 +149,11 @@ internal fun CheckableUnresolvedUserRowPreview() = val matrixUser = aMatrixUser() Column { CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value) - Divider() + HorizontalDivider() CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value) - Divider() + HorizontalDivider() CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value, enabled = false) - Divider() + HorizontalDivider() CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(AvatarSize.UserListItem), matrixUser.userId.value, enabled = false) } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt index 2b5d2f6800..13fcb40792 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt @@ -85,11 +85,11 @@ fun UnsavedAvatar( @Preview @Composable -fun UnsavedAvatarLightPreview() = ElementPreviewLight { ContentToPreview() } +internal fun UnsavedAvatarLightPreview() = ElementPreviewLight { ContentToPreview() } @Preview @Composable -fun UnsavedAvatarDarkPreview() = ElementPreviewDark { ContentToPreview() } +internal fun UnsavedAvatarDarkPreview() = ElementPreviewDark { ContentToPreview() } @Composable private fun ContentToPreview() { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt index 7995672e92..668b963bf1 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomMembers.kt @@ -57,14 +57,9 @@ fun MatrixRoom.getDirectRoomMember(roomMembersState: MatrixRoomMembersState): St val roomMembers = roomMembersState.roomMembers() return remember(roomMembersState) { derivedStateOf { - if (roomMembers == null) { - null - } else if (roomMembers.size == 2 && isDirect && isEncrypted) { - roomMembers.find { it.userId != this.sessionId } - } else { - null - } + roomMembers + ?.takeIf { it.size == 2 && isDirect && isEncrypted } + ?.find { it.userId != sessionId } } } } - diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt index 005a0ac747..f2a73545bf 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.produceState import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.powerlevels.canRedact import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage @Composable @@ -30,3 +31,10 @@ fun MatrixRoom.canSendMessageAsState(type: MessageEventType, updateKey: Long): S } } +@Composable +fun MatrixRoom.canRedactAsState(updateKey: Long): State { + return produceState(initialValue = false, key1 = updateKey) { + value = canRedact().getOrElse { false } + } +} + diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts index 111abc2bcc..c1e501d02a 100644 --- a/libraries/mediaupload/api/build.gradle.kts +++ b/libraries/mediaupload/api/build.gradle.kts @@ -38,5 +38,12 @@ android { api(projects.libraries.matrix.api) implementation(libs.inject) implementation(libs.coroutines.core) + + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.robolectric) } } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 1622ab2eef..899e92efc5 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -17,9 +17,13 @@ package io.element.android.libraries.mediaupload.api import android.net.Uri -import io.element.android.libraries.core.extensions.flatMap +import io.element.android.libraries.core.extensions.flatMapCatching import io.element.android.libraries.matrix.api.core.ProgressCallback +import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.room.MatrixRoom +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject class MediaSender @Inject constructor( @@ -27,6 +31,9 @@ class MediaSender @Inject constructor( private val room: MatrixRoom, ) { + private val ongoingUploadJobs = ConcurrentHashMap () + val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty() + suspend fun sendMedia( uri: Uri, mimeType: String, @@ -40,16 +47,25 @@ class MediaSender @Inject constructor( deleteOriginal = true, compressIfPossible = compressIfPossible ) - .flatMap { info -> + .flatMapCatching { info -> room.sendMedia(info, progressCallback) } + .onFailure { error -> + val job = ongoingUploadJobs.remove(Job) + if (error !is CancellationException) { + job?.cancel() + } + } + .onSuccess { + ongoingUploadJobs.remove(Job) + } } private suspend fun MatrixRoom.sendMedia( uploadInfo: MediaUploadInfo, - progressCallback: ProgressCallback? + progressCallback: ProgressCallback?, ): Result { - return when (uploadInfo) { + val handler = when (uploadInfo) { is MediaUploadInfo.Image -> { sendImage( file = uploadInfo.file, @@ -83,5 +99,11 @@ class MediaSender @Inject constructor( ) } } + + return handler + .flatMapCatching { uploadHandler -> + ongoingUploadJobs[Job] = uploadHandler + uploadHandler.await() + } } } diff --git a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTests.kt b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTests.kt new file mode 100644 index 0000000000..480cf8065f --- /dev/null +++ b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTests.kt @@ -0,0 +1,116 @@ +/* + * 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.libraries.mediaupload.api + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MediaSenderTests { + + @Test + fun `given an attachment when sending it the preprocessor always runs`() = runTest { + val preProcessor = FakeMediaPreProcessor() + val sender = aMediaSender(preProcessor) + + val uri = Uri.parse("content://image.jpg") + sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true) + + assertThat(preProcessor.processCallCount).isEqualTo(1) + } + + @Test + fun `given an attachment when sending it the MatrixRoom will call sendMedia`() = runTest { + val room = FakeMatrixRoom() + val sender = aMediaSender(room = room) + + val uri = Uri.parse("content://image.jpg") + sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true) + + assertThat(room.sendMediaCount).isEqualTo(1) + } + + @Test + fun `given a failure in the preprocessor when sending the whole process fails`() = runTest { + val preProcessor = FakeMediaPreProcessor().apply { + givenResult(Result.failure(Exception())) + } + val sender = aMediaSender(preProcessor) + + val uri = Uri.parse("content://image.jpg") + val result = sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true) + + assertThat(result.exceptionOrNull()).isNotNull() + } + + @Test + fun `given a failure in the media upload when sending the whole process fails`() = runTest { + val room = FakeMatrixRoom().apply { + givenSendMediaResult(Result.failure(Exception())) + } + val sender = aMediaSender(room = room) + + val uri = Uri.parse("content://image.jpg") + val result = sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true) + + assertThat(result.exceptionOrNull()).isNotNull() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `given a cancellation in the media upload when sending the job is cancelled`() = runTest(StandardTestDispatcher()) { + val room = FakeMatrixRoom() + val sender = aMediaSender(room = room) + val sendJob = launch { + val uri = Uri.parse("content://image.jpg") + sender.sendMedia(uri = uri, mimeType = "image/jpeg", compressIfPossible = true) + } + // Wait until several internal tasks run and the file is being uploaded + advanceTimeBy(3L) + + // Assert the file is being uploaded + assertThat(sender.hasOngoingMediaUploads).isTrue() + + // Cancel the coroutine + sendJob.cancel() + + // Wait for the coroutine cleanup to happen + advanceTimeBy(1L) + + // Assert the file is not being uploaded anymore + assertThat(sender.hasOngoingMediaUploads).isFalse() + } + + private fun aMediaSender( + preProcessor: MediaPreProcessor = FakeMediaPreProcessor(), + room: MatrixRoom = FakeMatrixRoom(), + ) = MediaSender( + preProcessor, + room, + ) +} diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt index 0cc7803578..d94414d2d7 100644 --- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -25,6 +25,9 @@ import java.io.File class FakeMediaPreProcessor : MediaPreProcessor { + var processCallCount = 0 + private set + private var result: Result = Result.success( MediaUploadInfo.AnyFile( File("test"), @@ -43,6 +46,7 @@ class FakeMediaPreProcessor : MediaPreProcessor { deleteOriginal: Boolean, compressIfPossible: Boolean ): Result = simulateLongTask { + processCallCount++ result } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt index 30f19aa31c..d9dba2a0d3 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt @@ -83,12 +83,12 @@ fun PermissionsView( @Preview @Composable -fun PermissionsViewLightPreview(@PreviewParameter(PermissionsViewStateProvider::class) state: PermissionsState) = +internal fun PermissionsViewLightPreview(@PreviewParameter(PermissionsViewStateProvider::class) state: PermissionsState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -fun PermissionsViewDarkPreview(@PreviewParameter(PermissionsViewStateProvider::class) state: PermissionsState) = +internal fun PermissionsViewDarkPreview(@PreviewParameter(PermissionsViewStateProvider::class) state: PermissionsState) = ElementPreviewDark { ContentToPreview(state) } @Composable diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index b0f3be6deb..30a686cad6 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -16,7 +16,7 @@ plugins { id("io.element.android-library") alias(libs.plugins.anvil) - kotlin("plugin.serialization") version "1.8.22" + kotlin("plugin.serialization") version "1.9.0" } android { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt index 29ac866347..e5af7785db 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.room.RoomMembershipState @@ -68,31 +69,27 @@ class NotifiableEventResolver @Inject constructor( userId = sessionId, roomId = roomId, eventId = eventId, - // FIXME should be true in the future, but right now it's broken - // (https://github.com/vector-im/element-x-android/issues/640#issuecomment-1612913658) - filterByPushRules = false, ).onFailure { Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.") }.getOrNull() // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event return notificationData?.asNotifiableEvent(sessionId) - ?: fallbackNotifiableEvent(sessionId, roomId, eventId) } private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent? { - return when (val content = this.event.content) { + return when (val content = this.content) { is NotificationContent.MessageLike.RoomMessage -> { buildNotifiableMessageEvent( sessionId = userId, + senderId = content.senderId, roomId = roomId, eventId = eventId, noisy = isNoisy, - timestamp = event.timestamp, + timestamp = this.timestamp, senderName = senderDisplayName, - senderId = senderId.value, body = descriptionFromMessageContent(content), - imageUriString = event.contentUrl, + imageUriString = this.contentUrl, roomName = roomDisplayName, roomIsDirect = isDirect, roomAvatarPath = roomAvatarUrl, @@ -109,7 +106,7 @@ class NotifiableEventResolver @Inject constructor( canBeReplaced = true, roomName = roomDisplayName, noisy = isNoisy, - timestamp = event.timestamp, + timestamp = this.timestamp, soundName = null, isRedacted = false, isUpdated = false, @@ -118,10 +115,10 @@ class NotifiableEventResolver @Inject constructor( title = null, // TODO check if title is needed anymore ) } else { - null + fallbackNotifiableEvent(userId, roomId, eventId) } } - else -> null + else -> fallbackNotifiableEvent(userId, roomId, eventId) } } @@ -177,6 +174,7 @@ class NotifiableEventResolver @Inject constructor( @Suppress("LongParameterList") private fun buildNotifiableMessageEvent( sessionId: SessionId, + senderId: UserId, roomId: RoomId, eventId: EventId, editedEventId: EventId? = null, @@ -184,7 +182,6 @@ private fun buildNotifiableMessageEvent( noisy: Boolean, timestamp: Long, senderName: String?, - senderId: String?, body: String?, // We cannot use Uri? type here, as that could trigger a // NotSerializableException when persisting this to storage @@ -202,6 +199,7 @@ private fun buildNotifiableMessageEvent( isUpdated: Boolean = false ) = NotifiableMessageEvent( sessionId = sessionId, + senderId = senderId, roomId = roomId, eventId = eventId, editedEventId = editedEventId, @@ -209,7 +207,6 @@ private fun buildNotifiableMessageEvent( noisy = noisy, timestamp = timestamp, senderName = senderName, - senderId = senderId, body = body, imageUriString = imageUriString, threadId = threadId, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt index 734c34b051..96a8b90f06 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomEventGroupInfo.kt @@ -23,17 +23,15 @@ import io.element.android.libraries.matrix.api.core.SessionId * Data class to hold information about a group of notifications for a room. */ data class RoomEventGroupInfo( - val sessionId: SessionId, - val roomId: RoomId, - val roomDisplayName: String, - val isDirect: Boolean = false -) { + val sessionId: SessionId, + val roomId: RoomId, + val roomDisplayName: String, + val isDirect: Boolean = false, // An event in the list has not yet been display - var hasNewEvent: Boolean = false - + val hasNewEvent: Boolean = false, // true if at least one on the not yet displayed event is noisy - var shouldBing: Boolean = false - var customSound: String? = null - var hasSmartReplyError: Boolean = false - var isUpdated: Boolean = false -} + val shouldBing: Boolean = false, + val customSound: String? = null, + val hasSmartReplyError: Boolean = false, + val isUpdated: Boolean = false, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt index 989ba2ad09..29d828d34c 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -85,12 +85,11 @@ class RoomGroupMessageCreator @Inject constructor( roomId = roomId, roomDisplayName = roomName, isDirect = !roomIsGroup, - ).also { - it.hasSmartReplyError = smartReplyErrors.isNotEmpty() - it.shouldBing = meta.shouldBing - it.customSound = events.last().soundName - it.isUpdated = events.last().isUpdated - }, + hasSmartReplyError = smartReplyErrors.isNotEmpty(), + shouldBing = meta.shouldBing, + customSound = events.last().soundName, + isUpdated = events.last().isUpdated, + ), threadId = lastKnownRoomEvent.threadId, largeIcon = largeBitmap, lastMessageTimestamp, @@ -108,7 +107,7 @@ class RoomGroupMessageCreator @Inject constructor( Person.Builder() .setName(event.senderName?.annotateForDebug(70)) .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath)) - .setKey(event.senderId) + .setKey(event.senderId.value) .build() } when { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt index 57a3eb45aa..1b6bb8a67a 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.currentRoomId @@ -32,10 +33,10 @@ data class NotifiableMessageEvent( override val eventId: EventId, override val editedEventId: EventId?, override val canBeReplaced: Boolean, + val senderId: UserId, val noisy: Boolean, val timestamp: Long, val senderName: String?, - val senderId: String?, val body: String?, // We cannot use Uri? type here, as that could trigger a // NotSerializableException when persisting this to storage diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt index f252765530..4b262983d4 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/SimpleNotifiableEvent.kt @@ -30,7 +30,7 @@ data class SimpleNotifiableEvent( val type: String?, val timestamp: Long, val soundName: String?, - override var canBeReplaced: Boolean, + override val canBeReplaced: Boolean, override val isRedacted: Boolean = false, override val isUpdated: Boolean = false ) : NotifiableEvent diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index aa1d0032e0..c3d68e52ac 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -100,15 +100,15 @@ class DefaultPushHandler @Inject constructor( } val clientSecret = pushData.clientSecret - val userId = if (clientSecret == null) { - // Should not happen. In this case, restore default session - null - } else { - // Get userId from client secret - pushClientSecret.getUserIdFromSecret(clientSecret) - } ?: run { - matrixAuthenticationService.getLatestSessionId() - } + // clientSecret should not be null. If this happens, restore default session + val userId = clientSecret + ?.let { + // Get userId from client secret + pushClientSecret.getUserIdFromSecret(clientSecret) + } + ?: run { + matrixAuthenticationService.getLatestSessionId() + } if (userId == null) { Timber.w("Unable to get a session") diff --git a/libraries/push/impl/src/main/res/values-ru/translations.xml b/libraries/push/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..697a0f01d8 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,58 @@ + + + diff --git a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..248fae8b0b --- /dev/null +++ b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,33 @@ + +"Позвонить" +"Прослушивание событий" +"Шумные уведомления" +"Бесшумные уведомления" +"** Не удалось отправить - пожалуйста, откройте комнату" +"Присоединиться" +"Отклонить" +"Пригласил вас в чат" +"Новые сообщения" +"Отреагировал на %1$s" +"Отметить как прочитанное" +"Пригласил вас в комнату" +"Я" +"Вы просматриваете уведомление! Нажмите на меня!" +"%1$s: %2$s" +"%1$s: %2$s %3$s" +"%1$s и %2$s" +"%1$s в %2$s" +"%1$s в %2$s и %3$s" ++ +- "%1$s: %2$d сообщение"
+- "%1$s: %2$d сообщения"
+- "%1$s: %2$d сообщений"
++ +- "%d уведомление"
+- "%d уведомления"
+- "%d уведомлений"
++ +- "%d приглашение"
+- "%d приглашения"
+- "%d приглашений"
++ +- "%d новое сообщение"
+- "%d новых сообщения"
+- "%d новых сообщений"
++ +- "%d непрочитанное уведомление"
+- "%d непрочитанных уведомления"
+- "%d непрочитанных уведомлений"
++ +- "%d комната"
+- "%d комнаты"
+- "%d комнат"
+"Выберите способ получения уведомлений" +"Фоновая синхронизация" +"Сервисы Google" +"Не найдены действующие службы Google Play. Уведомления могут работать некорректно." +"Уведомление" +"Быстрый ответ" ++ diff --git a/libraries/push/impl/src/main/res/values/dimens.xml b/libraries/push/impl/src/main/res/values/dimens.xml deleted file mode 100644 index ce2fee2015..0000000000 --- a/libraries/push/impl/src/main/res/values/dimens.xml +++ /dev/null @@ -1,21 +0,0 @@ - - -"通話" +"無聲通知" +"加入" +"拒絕" +"邀請您聊天" +"新訊息" +"標示為已讀" +"邀請您加入聊天室" +"我" +"您正在查看通知!點我!" ++ +- "%1$s:%2$d 則訊息"
++ +- "%d 個通知"
++ +- "%d 個邀請"
++ +- "%d 則新訊息"
++ +- "%d 個聊天室"
+"選擇接收通知的機制" +"背景同步" +"Google 服務" +"通知" +"快速回覆" +- - diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt index 57f28e72db..b9664ef577 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationIdProviderTest.kt @@ -25,7 +25,7 @@ class NotificationIdProviderTest { @Test fun `test notification id provider`() { val sut = NotificationIdProvider() - val offsetForASessionId = 305410 + val offsetForASessionId = 305_410 assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 0) assertThat(sut.getRoomMessagesNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 1) assertThat(sut.getRoomEventNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 2) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt index 9a998abf43..780d2abb71 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -84,7 +85,7 @@ fun aNotifiableMessageEvent( noisy = false, timestamp = 0, senderName = "sender-name", - senderId = "sending-id", + senderId = UserId("@sending-id:domain.com"), body = "message-body", roomId = roomId, threadId = threadId, diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt index 9dedf9648f..795c8bb1e8 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/PushDataFirebase.kt @@ -36,7 +36,7 @@ import io.element.android.libraries.pushproviders.api.PushData data class PushDataFirebase( val eventId: String?, val roomId: String?, - var unread: Int?, + val unread: Int?, val clientSecret: String? ) diff --git a/libraries/pushproviders/firebase/src/main/res/values/firebase.xml b/libraries/pushproviders/firebase/src/main/res/values/firebase.xml index 163717db91..b73238c79d 100644 --- a/libraries/pushproviders/firebase/src/main/res/values/firebase.xml +++ b/libraries/pushproviders/firebase/src/main/res/values/firebase.xml @@ -1,10 +1,10 @@ -50dp - -+ diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..d4830e9127 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -0,0 +1,213 @@ + +diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index abc4c0babc..a6565c25f0 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -16,7 +16,7 @@ plugins { id("io.element.android-library") alias(libs.plugins.anvil) - kotlin("plugin.serialization") version "1.8.22" + kotlin("plugin.serialization") version "1.9.0" } android { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/PushDataUnifiedPush.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/PushDataUnifiedPush.kt index f092d0167c..4485cb2c7f 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/PushDataUnifiedPush.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/PushDataUnifiedPush.kt @@ -47,7 +47,7 @@ data class PushDataUnifiedPush( data class PushDataUnifiedPushNotification( @SerialName("event_id") val eventId: String? = null, @SerialName("room_id") val roomId: String? = null, - @SerialName("counts") var counts: PushDataUnifiedPushCounts? = null, + @SerialName("counts") val counts: PushDataUnifiedPushCounts? = null, ) @Serializable diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 8c4c361707..3c0e0fa723 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.ripple.rememberRipple @@ -48,6 +49,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -65,6 +67,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -73,6 +76,7 @@ import io.element.android.libraries.designsystem.VectorIcons import io.element.android.libraries.designsystem.modifiers.applyIf import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.text.applyScaleUp import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text @@ -109,12 +113,15 @@ fun TextComposer( ) { AttachmentButton(onClick = onAddAttachment, modifier = Modifier.padding(vertical = 6.dp)) Spacer(modifier = Modifier.width(12.dp)) - var lineCount by remember { mutableStateOf(0) } + val roundCornerSmall = 20.dp.applyScaleUp() + val roundCornerLarge = 28.dp.applyScaleUp() + var lineCount by remember { mutableIntStateOf(0) } + val roundedCornerSize = remember(lineCount, composerMode) { if (lineCount > 1 || composerMode is MessageComposerMode.Special) { - 20.dp + roundCornerSmall } else { - 28.dp + roundCornerLarge } } val roundedCornerSizeState = animateDpAsState( @@ -124,7 +131,7 @@ fun TextComposer( ) ) val roundedCorners = RoundedCornerShape(roundedCornerSizeState.value) - val minHeight = 42.dp + val minHeight = 42.dp.applyScaleUp() val bgColor = ElementTheme.colors.bgSubtleSecondary // Change border color depending on focus var hasFocus by remember { mutableStateOf(false) } @@ -155,6 +162,9 @@ fun TextComposer( onTextLayout = { lineCount = it.lineCount }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), textStyle = defaultTypography.copy(color = MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(ElementTheme.colors.iconAccentTertiary), decorationBox = { innerTextField -> @@ -165,7 +175,12 @@ fun TextComposer( singleLine = false, visualTransformation = VisualTransformation.None, shape = roundedCorners, - contentPadding = PaddingValues(top = 10.dp, bottom = 10.dp, start = 12.dp, end = 42.dp), + contentPadding = PaddingValues( + top = 10.dp.applyScaleUp(), + bottom = 10.dp.applyScaleUp(), + start = 12.dp.applyScaleUp(), + end = 42.dp.applyScaleUp(), + ), interactionSource = remember { MutableInteractionSource() }, placeholder = { Text(stringResource(CommonStrings.common_message), style = defaultTypography) @@ -193,7 +208,7 @@ fun TextComposer( canSendMessage = composerCanSendMessage, onSendMessage = onSendMessage, composerMode = composerMode, - modifier = Modifier.padding(end = 6.dp, bottom = 6.dp) + modifier = Modifier.padding(end = 6.dp.applyScaleUp(), bottom = 6.dp.applyScaleUp()) ) } } @@ -253,7 +268,7 @@ private fun EditingModeView( tint = ElementTheme.materialColors.secondary, modifier = Modifier .padding(vertical = 8.dp) - .size(16.dp), + .size(16.dp.applyScaleUp()), ) Text( stringResource(CommonStrings.common_editing), @@ -270,11 +285,11 @@ private fun EditingModeView( tint = ElementTheme.materialColors.secondary, modifier = Modifier .padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp) - .size(16.dp) + .size(16.dp.applyScaleUp()) .clickable( enabled = true, onClick = onResetComposerMode, - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(bounded = false) ), ) @@ -333,11 +348,11 @@ private fun ReplyToModeView( tint = MaterialTheme.colorScheme.secondary, modifier = Modifier .padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp) - .size(16.dp) + .size(16.dp.applyScaleUp()) .clickable( enabled = true, onClick = onResetComposerMode, - interactionSource = MutableInteractionSource(), + interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(bounded = false) ), ) @@ -351,13 +366,13 @@ private fun AttachmentButton( ) { Surface( modifier - .size(30.dp) + .size(30.dp.applyScaleUp()) .clickable(onClick = onClick), shape = CircleShape, color = ElementTheme.colors.iconPrimary ) { Image( - modifier = Modifier.size(12.5f.dp), + modifier = Modifier.size(12.5f.dp.applyScaleUp()), painter = painterResource(R.drawable.ic_add_attachment), contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment), contentScale = ContentScale.Inside, @@ -376,15 +391,15 @@ private fun BoxScope.SendButton( composerMode: MessageComposerMode, modifier: Modifier = Modifier, ) { - val interactionSource = MutableInteractionSource() + val interactionSource = remember { MutableInteractionSource() } Box( modifier = modifier .clip(CircleShape) .background(if (canSendMessage) ElementTheme.colors.iconAccentTertiary else Color.Transparent) - .size(30.dp) + .size(30.dp.applyScaleUp()) .align(Alignment.BottomEnd) .applyIf(composerMode !is MessageComposerMode.Edit, ifTrue = { - padding(start = 1.dp) // Center the arrow in the circle + padding(start = 1.dp.applyScaleUp()) // Center the arrow in the circle }) .clickable( enabled = canSendMessage, @@ -404,7 +419,7 @@ private fun BoxScope.SendButton( else -> stringResource(CommonStrings.action_send) } Icon( - modifier = Modifier.size(16.dp), + modifier = Modifier.size(16.dp.applyScaleUp()), resourceId = iconId, contentDescription = contentDescription, // Exception here, we use Color.White instead of ElementTheme.colors.iconOnSolidPrimary @@ -415,7 +430,7 @@ private fun BoxScope.SendButton( @DayNightPreviews @Composable -fun TextComposerSimplePreview() = ElementPreview { +internal fun TextComposerSimplePreview() = ElementPreview { Column { TextComposer( onSendMessage = {}, @@ -446,7 +461,7 @@ fun TextComposerSimplePreview() = ElementPreview { @DayNightPreviews @Composable -fun TextComposerEditPreview() = ElementPreview { +internal fun TextComposerEditPreview() = ElementPreview { TextComposer( onSendMessage = {}, onComposerTextChange = {}, @@ -459,7 +474,7 @@ fun TextComposerEditPreview() = ElementPreview { @DayNightPreviews @Composable -fun TextComposerReplyPreview() = ElementPreview { +internal fun TextComposerReplyPreview() = ElementPreview { Column { TextComposer( onSendMessage = {}, diff --git a/libraries/textcomposer/src/main/res/values-ru/translations.xml b/libraries/textcomposer/src/main/res/values-ru/translations.xml new file mode 100644 index 0000000000..9f7324f086 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-ru/translations.xml @@ -0,0 +1,18 @@ + + 912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com https://vector-alpha.firebaseio.com 912726360885 AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c vector-alpha.appspot.com -vector-alpha +vector-alpha + diff --git a/libraries/textcomposer/src/main/res/values-zh-rTW/translations.xml b/libraries/textcomposer/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..93777d4ca5 --- /dev/null +++ b/libraries/textcomposer/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,17 @@ + +"Прикрепить файл" +"Переключить список маркеров" +"Переключить блок кода" +"Сообщение" +"Применить жирный шрифт" +"Применить курсивный формат" +"Применить формат зачеркивания" +"Применить формат подчеркивания" +"Переключение полноэкранного режима" +"Отступ" +"Применить встроенный формат кода" +"Установить ссылку" +"Переключить нумерованный список" +"Переключить цитату" +"Без отступа" ++ diff --git a/libraries/theme/build.gradle.kts b/libraries/theme/build.gradle.kts index 9488565c80..0c5c0b9548 100644 --- a/libraries/theme/build.gradle.kts +++ b/libraries/theme/build.gradle.kts @@ -23,6 +23,8 @@ android { namespace = "io.element.android.libraries.theme" dependencies { + api(libs.androidx.compose.material3) + ksp(libs.showkase.processor) kspTest(libs.showkase.processor) diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/ElementTheme.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/ElementTheme.kt index f273c2dd64..ae73f72f97 100644 --- a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/ElementTheme.kt +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/ElementTheme.kt @@ -68,6 +68,14 @@ object ElementTheme { */ val typography: TypographyTokens = TypographyTokens + /** + * Material 3 [Typography] tokens. In Figma, these have the `M3 Typography/` prefix. + */ + val materialTypography: Typography + @Composable + @ReadOnlyComposable + get() = MaterialTheme.typography + /** * Returns whether the theme version used is the light or the dark one. */ diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/LegacyColors.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/LegacyColors.kt index b797dab86a..2e705c8c79 100644 --- a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/LegacyColors.kt +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/LegacyColors.kt @@ -17,6 +17,8 @@ package io.element.android.libraries.theme import androidx.compose.ui.graphics.Color +import io.element.android.libraries.theme.compound.generated.internal.DarkDesignTokens +import io.element.android.libraries.theme.compound.generated.internal.LightDesignTokens // ================================================================================================= // IMPORTANT! @@ -26,3 +28,6 @@ import androidx.compose.ui.graphics.Color // ================================================================================================= val LinkColor = Color(0xFF0086E6) + +val SnackBarLabelColorLight = LightDesignTokens.colorGray700 +val SnackBarLabelColorDark = DarkDesignTokens.colorGray700 diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/MaterialThemeColors.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/MaterialThemeColors.kt index d211869f71..3d359594e1 100644 --- a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/MaterialThemeColors.kt +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/MaterialThemeColors.kt @@ -91,7 +91,7 @@ internal val materialColorSchemeDark = darkColorScheme( @Preview @Composable -fun ColorsSchemePreviewLight() = ColorsSchemePreview( +internal fun ColorsSchemePreviewLight() = ColorsSchemePreview( Color.Black, Color.White, materialColorSchemeLight, @@ -99,7 +99,7 @@ fun ColorsSchemePreviewLight() = ColorsSchemePreview( @Preview @Composable -fun ColorsSchemePreviewDark() = ColorsSchemePreview( +internal fun ColorsSchemePreviewDark() = ColorsSchemePreview( Color.White, Color.Black, materialColorSchemeDark, diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundTypography.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundTypography.kt index fe72a5effe..2da86f7882 100644 --- a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundTypography.kt +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/CompoundTypography.kt @@ -17,9 +17,11 @@ package io.element.android.libraries.theme.compound import androidx.compose.material3.Typography +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import io.element.android.libraries.theme.compound.generated.TypographyTokens @@ -41,6 +43,8 @@ internal val defaultHeadlineSmall = TextStyle( lineHeight = 32.sp, fontSize = 24.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) // 22px (Material) vs 20px, it's the closest one diff --git a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/TypographyTokens.kt b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/TypographyTokens.kt index 00a0b82fd7..68ff1d2e03 100644 --- a/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/TypographyTokens.kt +++ b/libraries/theme/src/main/kotlin/io/element/android/libraries/theme/compound/generated/TypographyTokens.kt @@ -1,21 +1,7 @@ -/* - * 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. - */ + // Do not edit directly -// Generated on Tue, 27 Jun 2023 13:31:52 GMT +// Generated on Fri, 28 Jul 2023 10:11:16 GMT @@ -27,6 +13,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.style.LineHeightStyle object TypographyTokens { val fontBodyLgMedium = TextStyle( @@ -35,6 +23,8 @@ object TypographyTokens { lineHeight = 22.sp, fontSize = 16.sp, letterSpacing = 0.015629999999999998.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontBodyLgRegular = TextStyle( fontFamily = FontFamily.Default, @@ -42,6 +32,8 @@ object TypographyTokens { lineHeight = 22.sp, fontSize = 16.sp, letterSpacing = 0.015629999999999998.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontBodyMdMedium = TextStyle( fontFamily = FontFamily.Default, @@ -49,6 +41,8 @@ object TypographyTokens { lineHeight = 20.sp, fontSize = 14.sp, letterSpacing = 0.01786.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontBodyMdRegular = TextStyle( fontFamily = FontFamily.Default, @@ -56,6 +50,8 @@ object TypographyTokens { lineHeight = 20.sp, fontSize = 14.sp, letterSpacing = 0.01786.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontBodySmMedium = TextStyle( fontFamily = FontFamily.Default, @@ -63,6 +59,8 @@ object TypographyTokens { lineHeight = 17.sp, fontSize = 12.sp, letterSpacing = 0.03333.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontBodySmRegular = TextStyle( fontFamily = FontFamily.Default, @@ -70,6 +68,8 @@ object TypographyTokens { lineHeight = 17.sp, fontSize = 12.sp, letterSpacing = 0.03333.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontBodyXsMedium = TextStyle( fontFamily = FontFamily.Default, @@ -77,6 +77,8 @@ object TypographyTokens { lineHeight = 15.sp, fontSize = 11.sp, letterSpacing = 0.04545.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontBodyXsRegular = TextStyle( fontFamily = FontFamily.Default, @@ -84,6 +86,8 @@ object TypographyTokens { lineHeight = 15.sp, fontSize = 11.sp, letterSpacing = 0.04545.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontHeadingLgBold = TextStyle( fontFamily = FontFamily.Default, @@ -91,6 +95,8 @@ object TypographyTokens { lineHeight = 34.sp, fontSize = 28.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontHeadingLgRegular = TextStyle( fontFamily = FontFamily.Default, @@ -98,6 +104,8 @@ object TypographyTokens { lineHeight = 34.sp, fontSize = 28.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontHeadingMdBold = TextStyle( fontFamily = FontFamily.Default, @@ -105,6 +113,8 @@ object TypographyTokens { lineHeight = 27.sp, fontSize = 22.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontHeadingMdRegular = TextStyle( fontFamily = FontFamily.Default, @@ -112,6 +122,8 @@ object TypographyTokens { lineHeight = 27.sp, fontSize = 22.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontHeadingSmMedium = TextStyle( fontFamily = FontFamily.Default, @@ -119,6 +131,8 @@ object TypographyTokens { lineHeight = 25.sp, fontSize = 20.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontHeadingSmRegular = TextStyle( fontFamily = FontFamily.Default, @@ -126,6 +140,8 @@ object TypographyTokens { lineHeight = 25.sp, fontSize = 20.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontHeadingXlBold = TextStyle( fontFamily = FontFamily.Default, @@ -133,6 +149,8 @@ object TypographyTokens { lineHeight = 41.sp, fontSize = 34.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) val fontHeadingXlRegular = TextStyle( fontFamily = FontFamily.Default, @@ -140,5 +158,7 @@ object TypographyTokens { lineHeight = 41.sp, fontSize = 34.sp, letterSpacing = 0.em, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) ) } diff --git a/libraries/theme/src/main/res/drawable/ic_chat.xml b/libraries/theme/src/main/res/drawable/ic_chat.xml deleted file mode 100644 index 1fef824a1d..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_chat.xml +++ /dev/null @@ -1,9 +0,0 @@ -"新增附件" +"切換項目編號" +"切換程式碼區塊" +"訊息" +"套用粗體" +"套用斜體" +"套用刪除線" +"套用底線" +"切換全螢幕模式" +"增加縮排" +"設定連結" +"切換數字編號" +"切換引用" +"減少縮排" +- diff --git a/libraries/theme/src/main/res/drawable/ic_check.xml b/libraries/theme/src/main/res/drawable/ic_check.xml deleted file mode 100644 index e92733095b..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_check_circle.xml b/libraries/theme/src/main/res/drawable/ic_check_circle.xml deleted file mode 100644 index ad3aacbe28..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_check_circle.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_chevron.xml b/libraries/theme/src/main/res/drawable/ic_chevron.xml deleted file mode 100644 index 4ecd3f16b0..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_chevron.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_close.xml b/libraries/theme/src/main/res/drawable/ic_close.xml deleted file mode 100644 index f334767b67..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_close.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_computer.xml b/libraries/theme/src/main/res/drawable/ic_computer.xml deleted file mode 100644 index e2748c2d4a..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_computer.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_delete.xml b/libraries/theme/src/main/res/drawable/ic_delete.xml deleted file mode 100644 index 413a570210..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_delete.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_error.xml b/libraries/theme/src/main/res/drawable/ic_error.xml deleted file mode 100644 index d978824039..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_error.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_info.xml b/libraries/theme/src/main/res/drawable/ic_info.xml deleted file mode 100644 index 69865e325a..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_info.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_lock.xml b/libraries/theme/src/main/res/drawable/ic_lock.xml deleted file mode 100644 index 2ada59e82f..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_lock.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_mobile.xml b/libraries/theme/src/main/res/drawable/ic_mobile.xml deleted file mode 100644 index f2c46be357..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_mobile.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_thread.xml b/libraries/theme/src/main/res/drawable/ic_thread.xml deleted file mode 100644 index d3293fab5a..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_thread.xml +++ /dev/null @@ -1,13 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_user.xml b/libraries/theme/src/main/res/drawable/ic_user.xml deleted file mode 100644 index 5f61985ce4..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_user.xml +++ /dev/null @@ -1,11 +0,0 @@ -- - - diff --git a/libraries/theme/src/main/res/drawable/ic_visibility_invisible.xml b/libraries/theme/src/main/res/drawable/ic_visibility_invisible.xml deleted file mode 100644 index 3f20783ee4..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_visibility_invisible.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_visibility_visible.xml b/libraries/theme/src/main/res/drawable/ic_visibility_visible.xml deleted file mode 100644 index 1283a1512b..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_visibility_visible.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/theme/src/main/res/drawable/ic_web_browser.xml b/libraries/theme/src/main/res/drawable/ic_web_browser.xml deleted file mode 100644 index 080ce75905..0000000000 --- a/libraries/theme/src/main/res/drawable/ic_web_browser.xml +++ /dev/null @@ -1,9 +0,0 @@ -- - diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 10694181da..0f5259e663 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -94,7 +94,7 @@- "Passwort" "Personen" "Permalink" -"Datenschutzerklärung" +"Datenschutzerklärung" "Reaktionen" "Aktualisiere…" "Auf %1$s antworten" @@ -145,7 +145,7 @@"%1$s konnte nicht auf deinen Standort zugreifen. Bitte versuche es später erneut." "Einige Nachrichten wurden nicht gesendet" "Entschuldigung, ein Fehler ist aufgetreten." -"🔐️ Besuchen Sie mich auf %1$s" +"🔐️ Besuche mich auf %1$s" "Hey, sprich mit mir auf %1$s: %2$s" "Bist du sicher, dass du diesen Raum verlassen willst? Du bist die einzige Person hier. Wenn du gehst, kann in Zukunft niemand mehr beitreten, auch du nicht." "Bist du dir sicher, dass du den Raum verlassen möchtest? Dieser Raum ist nicht öffentlich und du kannst ihm ohne eine Einladung nicht mehr beitreten." @@ -164,10 +164,10 @@"Neu" "Teile Analyse-Daten" "Medienauswahl fehlgeschlagen, bitte versuche es erneut." -"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuchen Sie es erneut." +"Fehler bei der Verarbeitung von Medien zum Hochladen, bitte versuche es erneut." "Hochladen von Medien fehlgeschlagen, bitte versuchen Sie es erneut." "Dies ist ein einmaliger Vorgang, danke fürs Warten." -"Deinen Account einrichten" +"Dein Konto einrichten" "Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Benutzers ausblenden möchtest" "Standort teilen" "Meinen Standort teilen" @@ -184,7 +184,7 @@"Fehler" "Erfolg" "Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen." -"Sie können alle unsere Nutzerbedingungen %1$s lesen." +"Du kannst alle unsere Nutzerbedingungen %1$s lesen." "hier" "Nutzer blockieren" + diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index 726d1b1505..14dd5626c8 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -40,6 +40,7 @@"Скрыть пароль" +"Отправить файлы" +"Показать пароль" +"Меню пользователя" +"Разрешить" +"Назад" +"Отмена" +"Выбрать фото" +"Очистить" +"Закрыть" +"Полная проверка" +"Подтвердить" +"Продолжить" +"Копировать" +"Скопировать ссылку" +"Скопировать ссылку в сообщение" +"Создать" +"Создать комнату" +"Отклонить" +"Отключить" +"Готово" +"Редактировать" +"Включить" +"Забыли пароль?" +"Переслать" +"Пригласить" +"Пригласить друзей" +"Пригласить друзей в %1$s" +"Пригласите пользователей в %1$s" +"Приглашения" +"Подробнее" +"Выйти" +"Покинуть комнату" +"Далее" +"Нет" +"Не сейчас" +"Ок" +"Открыть с помощью" +"Быстрый ответ" +"Цитата" +"Реакция" +"Удалить" +"Ответить" +"Сообщить об ошибке" +"Пожаловаться на содержание" +"Повторить" +"Повторите расшифровку" +"Сохранить" +"Поиск" +"Отправить" +"Отправить сообщение" +"Поделиться" +"Поделиться ссылкой" +"Пропустить" +"Начать" +"Начать чат " +"Начать подтверждение" +"Нажмите, чтобы загрузить карту" +"Сделать фото" +"Показать источник" +"Да" +"О приложении" +"Политика допустимого использования" +"Аналитика" +"Аудио" +"Пузыри" +"Авторское право" +"Создание комнаты…" +"Покинул комнату" +"Ошибка расшифровки" +"Для разработчика" +"(изменено)" +"Редактирование" +"%1$s%2$s" +"Шифрование включено" +"Ошибка" +"Файл" +"Файл сохранен в «Загрузки»" +"Переслать сообщение" +"GIF" +"Изображения" +"Идентификатор Matrix ID не найден, приглашение может быть не получено." +"Покинуть комнату" +"Ссылка скопирована в буфер обмена" +"Загрузка…" +"Сообщение" +"Оформление сообщений" +"Сообщение удалено" +"Современный" +"Без звука" +"Ничего не найдено" +"Не в сети" +"Пароль" +"Пользователи" +"Постоянная ссылка" +"Политика конфиденциальности" +"Реакции" +"Обновление…" +"Отвечает на %1$s" +"Сообщить об ошибке" +"Отчет отправлен" +"Название комнаты" +"например, название вашего проекта" +"Поиск человека" +"Результаты поиска" +"Безопасность" +"Выберите свой сервер" +"Отправка…" +"Сервер не поддерживается" +"Адрес сервера" +"Настройки" +"Делится местонахождением" +"Начало чата…" +"Стикер" +"Успешно" +"Рекомендации" +"Синхронизация" +"Уведомление о третьей стороне" +"Тема" +"О чем эта комната?" +"Невозможно расшифровать" +"Не удалось отправить приглашения одному или нескольким пользователям." +"Не удалось отправить приглашение(я)" +"Включить звук" +"Неподдерживаемое событие" +"Имя пользователя" +"Проверка отменена" +"Проверка завершена" +"Видео" +"Ожидание…" +"Подтверждение" +"Предупреждение" +"Деятельность" +"Флаги" +"Еда и напитки" +"Животные и природа" +"Объекты" +"Смайлы и люди" +"Путешествия и места" +"Символы" +"Не удалось создать постоянную ссылку" +"Не удалось загрузить карту %1$s. Пожалуйста, повторите попытку позже." +"Не удалось загрузить сообщения" +"%1$s не удалось получить доступ к вашему местоположению. Пожалуйста, повторите попытку позже." +"У %1$s нет разрешения на доступ к вашему местоположению. Вы можете разрешить доступ в Настройках." +"У %1$s нет разрешения на доступ к вашему местоположению. Разрешите доступ ниже." +"Некоторые сообщения не были отправлены" +"Извините, произошла ошибка" +"🔐️ Присоединяйтесь ко мне в %1$s" +"Привет, поговори со мной по %1$s: %2$s" +"Вы уверены, что хотите покинуть эту комнату? Вы здесь единственный человек. Если вы уйдете, никто не сможет присоединиться в будущем, включая вас." +"Вы уверены, что хотите покинуть эту комнату? Эта комната не является публичной, и Вы не сможете присоединиться к ней без приглашения." +"Вы уверены, что хотите покинуть комнату?" +"%1$s Android" ++ +- "%1$d участник"
+- "%1$d участников"
+- "%1$d участников"
++ +- "%d голос"
+- "%d голоса"
+- "%d голосов"
+"Rageshake сообщит об ошибке" +"Кажется, вы трясли телефон. Хотите открыть экран отчета об ошибке?" +"Это сообщение будет передано администратору вашего домашнего сервера. Они не смогут прочитать зашифрованные сообщения." +"Причина, по которой вы пожаловались на этот контент" +"Это начало %1$s." +"Это начало разговора." +"Новый" +"Делитесь данными аналитики" +"Не удалось выбрать носитель, попробуйте еще раз." +"Не удалось обработать медиафайл для загрузки, попробуйте еще раз." +"Не удалось загрузить медиафайлы, попробуйте еще раз." +"Это одноразовый процесс, спасибо, что подождали." +"Настройка учетной записи." +"Дополнительные параметры" +"Аудио и видео звонки" +"Прямые чаты" +"Включить уведомления на данном устройстве" +"Групповые чаты" +"Упоминания" +"Все" +"Упоминания" +"Уведомить меня" +"Уведомить меня в @room" +"Чтобы получать уведомления, измените свой %1$s." +"Настройки системы" +"Системные уведомления выключены" +"Уведомления" +"Отметьте, хотите ли вы скрыть все текущие и будущие сообщения от этого пользователя" +"Поделиться местоположением" +"Поделиться моим местоположением" +"Открыть в Apple Maps" +"Открыть в Google Картах" +"Открыть в OpenStreetMap" +"Поделиться этим местоположением" +"Местоположение" +"Rageshake" +"Порог обнаружения" +"Основные" +"Версия: %1$s (%2$s)" +"en" +"Ошибка" +"Успешно" +"Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы." +"Вы можете ознакомиться со всеми нашими условиями %1$s." +"здесь" +"Заблокировать пользователя" +"Otvoriť pomocou" "Rýchla odpoveď" "Citovať" +"Reagovať" "Odstrániť" "Odpovedať" "Nahlásiť chybu" @@ -94,6 +95,9 @@"Heslo" "Ľudia" "Trvalý odkaz" +"Výsledné hlasovanie: %1$s" +"Celkový počet hlasov: %1$s" +"Výsledky sa zobrazia po ukončení ankety" "Zásady ochrany osobných údajov" "Reakcie" "Obnovuje sa…" @@ -143,8 +147,8 @@"%1$s nedokázal načítať mapu. Skúste to prosím neskôr." "Načítanie správ zlyhalo" "%1$s nemohol získať prístup k vašej polohe. Skúste to prosím neskôr." -"Ak chcete odoslať polohu, povoľte %1$s prístup k vašej polohe z obrazovky nastavení." -"Ak chcete odoslať polohu, povoľte %1$s prístup k vašej polohe v nasledujúcom dialógovom okne." +"%1$s nemá povolenie na prístup k vašej polohe. Prístup môžete zapnúť v Nastaveniach." +"%1$s nemá povolenie na prístup k vašej polohe. Povoľte prístup nižšie." "Niektoré správy neboli odoslané" "Prepáčte, vyskytla sa chyba" "🔐️ Pripojte sa ku mne na %1$s" @@ -158,6 +162,11 @@- "%1$d členovia"
- "%1$d členov"
++ - "1 hlas"
+- "%d hlasy"
+- "%d hlasov"
+"Zúrivo potriasť pre nahlásenie chyby" "Zdá sa, že zúrivo trasiete telefónom. Chcete otvoriť obrazovku s nahlásením chýb?" "Táto správa bude nahlásená správcovi vášho domovského servera. Nebude môcť prečítať žiadne šifrované správy." @@ -171,7 +180,18 @@"Nepodarilo sa nahrať médiá, skúste to prosím znova." "Ide o jednorazový proces, ďakujeme za trpezlivosť." "Nastavenie vášho účtu." +"Ďalšie nastavenia" +"Audio a video hovory" +"Priame konverzácie" +"Pri priamych rozhovoroch ma upozorniť na" +"Pri skupinových rozhovoroch ma upozorniť na" "Povoliť oznámenia na tomto zariadení" +"Skupinové rozhovory" +"Zmienky" +"Všetky" +"Zmienky" +"Upozorniť ma na" +"Upozorniť ma na @miestnosť" "Ak chcete dostávať oznámenia, zmeňte prosím svoje %1$s." "nastavenia systému" "Systémové oznámenia sú vypnuté" diff --git a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 0000000000..701a6243ac --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,169 @@ + ++ diff --git a/libraries/ui-strings/src/main/res/values/donottranslate.xml b/libraries/ui-strings/src/main/res/values/donottranslate.xml deleted file mode 100755 index 910ce31c41..0000000000 --- a/libraries/ui-strings/src/main/res/values/donottranslate.xml +++ /dev/null @@ -1,17 +0,0 @@ - -"隱藏密碼" +"傳送檔案" +"顯示密碼" +"使用者選單" +"接受" +"返回" +"取消" +"選擇照片" +"清除" +"關閉" +"完成驗證" +"確認" +"繼續" +"複製" +"複製連結" +"建立" +"建立聊天室" +"停用" +"完成" +"編輯" +"啟用" +"忘記密碼?" +"轉寄" +"邀請" +"邀請朋友" +"邀請朋友使用%1$s" +"邀請夥伴使用%1$s" +"邀請" +"了解更多" +"離開" +"離開聊天室" +"下一個" +"否" +"以後再說" +"OK" +"用其他方式開啟" +"快速回覆" +"引用" +"回應" +"移除" +"回覆" +"檢舉內容" +"再試一次" +"再次嘗試解密" +"儲存" +"搜尋" +"傳送" +"傳送訊息" +"分享" +"分享連結" +"跳過" +"開始" +"開始聊天" +"開始驗證" +"點擊以載入地圖" +"拍照" +"檢視原始碼" +"是" +"關於" +"分析" +"音訊" +"著作權" +"正在建立聊天室…" +"離開聊天室" +"解密錯誤" +"開發者選項" +"(已編輯)" +"編輯中" +"已啟用加密" +"錯誤" +"檔案" +"檔案已儲存至 Downloads" +"訊息轉寄" +"GIF" +"圖片" +"找不到此 Matrix ID,因此可能沒有人會收到邀請。" +"正在離開聊天室" +"連結已複製到剪貼簿" +"載入中…" +"訊息" +"訊息布局" +"訊息已移除" +"現代" +"關閉通知" +"查無結果" +"離線" +"密碼" +"夥伴" +"永久連結" +"結果將在投票結束後公佈" +"隱私權政策" +"回應" +"重新整理…" +"正在回覆%1$s" +"聊天室名稱" +"範例:您的計畫名稱" +"搜尋結果" +"選擇您的伺服器" +"傳送中…" +"伺服器 URL" +"設定" +"貼圖" +"成功" +"建議" +"同步中" +"主題" +"無法解密" +"無法發送邀請給一或多個使用者。" +"無法發送邀請" +"開啟通知" +"使用者名稱" +"驗證已取消" +"驗證完成" +"影片" +"等待中…" +"確認" +"警告" +"活動" +"旗幟" +"食物與飲料" +"動物與大自然" +"物品" +"表情與人物" +"旅行與景點" +"標誌" +"無法建立永久連結" +"%1$s無法載入地圖。請稍後再試。" +"無法載入訊息" +"%1$s無法取得您的位置。請稍後再試。" +"有些訊息尚未傳送" +"您確定要離開聊天室嗎?這裡只有您一個人。如果您離開了,包含您在內的所有人都無法再進入此聊天室。" +"您確定要離開聊天室嗎?此聊天室不是公開的,如果沒有收到邀請,您無法重新加入。" +"您確定要離開聊天室嗎?" +"%1$s Android" ++ +- "%1$d 位成員"
++ +- "%d 票"
+"檢舉這個內容的原因" +"新訊息" +"無法上傳媒體檔案,請稍後再試。" +"設定您的帳號" +"其他設定" +"私訊" +"在這個裝置上開啟通知" +"群組聊天" +"提及" +"提及" +"系統設定" +"已關閉系統通知" +"通知" +"分享位置" +"分享我的位置" +"在 Apple 地圖中開啟" +"在 Google 地圖中開啟" +"在開放街圖(OpenStreetMap) 中開啟" +"分享這個位置" +"位置" +"一般" +"版本:%1$s(%2$s)" +"zh-tw" +"錯誤" +"成功" +"封鎖使用者" +- - diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index b17d8f337f..3b4c305ffc 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -40,6 +40,7 @@… -– - - -Not implemented yet in ${app_name} - - -Cut the slack from teams. - -Crash the application. - - -© MapTiler © OpenStreetMap contributors -"Open with" "Quick reply" "Quote" +"React" "Remove" "Reply" "Report bug" @@ -94,6 +95,9 @@"Password" "People" "Permalink" +"Final votes: %1$s" +"Total votes: %1$s" +"Results will show after the poll has ended" "Privacy policy" "Reactions" "Refreshing…" @@ -157,6 +161,10 @@- "%1$d member"
- "%1$d members"
++ - "%d vote"
+- "%d votes"
+"Rageshake to report bug" "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?" "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." @@ -170,7 +178,19 @@"Failed uploading media, please try again." "This is a one time process, thanks for waiting." "Setting up your account." +"Additional settings" +"Audio and video calls" +"Direct chats" +"An error occurred while updating the notification setting." +"On direct chats, notify me for" +"On group chats, notify me for" "Enable notifications on this device" +"Group chats" +"Mentions" +"All" +"Mentions" +"Notify me for" +"Notify me on @room" "To receive notifications, please change your %1$s." "system settings" "System notifications turned off" diff --git a/plugins/settings.gradle.kts b/plugins/settings.gradle.kts index defcb6f17b..7e2ce1ea50 100644 --- a/plugins/settings.gradle.kts +++ b/plugins/settings.gradle.kts @@ -14,6 +14,8 @@ * limitations under the License. */ +rootProject.name = "ElementX_plugins" + dependencyResolutionManagement { repositories { mavenCentral() diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 2e89afa70f..658603cb56 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -56,12 +56,12 @@ private const val versionMinor = 1 // Note: even values are reserved for regular release, odd values for hotfix release. // When creating a hotfix, you should decrease the value, since the current value // is the value for the next regular release. -private const val versionPatch = 2 +private const val versionPatch = 4 object Versions { val versionCode = 4_000_000 + versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch val versionName = "$versionMajor.$versionMinor.$versionPatch" - const val compileSdk = 33 + const val compileSdk = 34 const val targetSdk = 33 const val minSdk = 23 val javaCompileVersion = JavaVersion.VERSION_17 diff --git a/plugins/src/main/kotlin/extension/CommonExtension.kt b/plugins/src/main/kotlin/extension/CommonExtension.kt index 6b9523d437..e3f7b3682e 100644 --- a/plugins/src/main/kotlin/extension/CommonExtension.kt +++ b/plugins/src/main/kotlin/extension/CommonExtension.kt @@ -45,6 +45,7 @@ fun CommonExtension<*, *, *, *, *>.androidConfig(project: Project) { checkDependencies = true abortOnError = true ignoreTestFixturesSources = true + checkGeneratedSources = false } } @@ -71,6 +72,7 @@ fun CommonExtension<*, *, *, *, *>.composeConfig(libs: LibrariesForLibs) { // error.add("ComposableLambdaParameterNaming") error.add("ComposableLambdaParameterPosition") ignoreTestFixturesSources = true + checkGeneratedSources = false } } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt index 21d6648a41..a915e70046 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -26,11 +26,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.view.WindowCompat -import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.auth.RustMatrixAuthenticationService import io.element.android.libraries.network.useragent.SimpleUserAgentProvider import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.libraries.theme.ElementTheme import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock import kotlinx.coroutines.runBlocking import java.io.File @@ -39,15 +40,22 @@ class MainActivity : ComponentActivity() { private val matrixAuthenticationService: MatrixAuthenticationService by lazy { val baseDirectory = File(applicationContext.filesDir, "sessions") - + val userAgentProvider = SimpleUserAgentProvider("MinimalSample") + val sessionStore = InMemorySessionStore() RustMatrixAuthenticationService( - context = applicationContext, baseDirectory = baseDirectory, - appCoroutineScope = Singleton.appScope, coroutineDispatchers = Singleton.coroutineDispatchers, - sessionStore = InMemorySessionStore(), - clock = DefaultSystemClock(), - userAgentProvider = SimpleUserAgentProvider("MinimalSample") + sessionStore = sessionStore, + userAgentProvider = userAgentProvider, + rustMatrixClientFactory = RustMatrixClientFactory( + context = applicationContext, + baseDirectory = baseDirectory, + appCoroutineScope = Singleton.appScope, + coroutineDispatchers = Singleton.coroutineDispatchers, + sessionStore = sessionStore, + userAgentProvider = userAgentProvider, + clock = DefaultSystemClock() + ) ) } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index 41dbc8bfe2..faaccc9b8e 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -68,7 +68,7 @@ class RoomListScreen( inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers), leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers), roomListDataSource = RoomListDataSource( - roomSummaryDataSource = matrixClient.roomSummaryDataSource, + roomListService = matrixClient.roomListService, lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), roomLastMessageFormatter = DefaultRoomLastMessageFormatter( sp = stringProvider, @@ -113,7 +113,9 @@ class RoomListScreen( } onDispose { Timber.w("Stop sync!") - matrixClient.syncService().stopSync() + runBlocking { + matrixClient.syncService().stopSync() + } } } } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt index 5f8c6555a5..027da552fa 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt @@ -17,19 +17,41 @@ package io.element.android.samples.minimal import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.matrix.impl.tracing.setupTracing -import io.element.android.libraries.matrix.api.tracing.TracingConfigurations +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.matrix.api.tracing.TracingConfiguration +import io.element.android.libraries.matrix.api.tracing.TracingFilterConfigurations +import io.element.android.libraries.matrix.api.tracing.WriteToFilesConfiguration +import io.element.android.libraries.matrix.impl.tracing.RustTracingService import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.plus -import timber.log.Timber object Singleton { + private val buildMeta = BuildMeta( + isDebuggable = true, + buildType = BuildType.DEBUG, + applicationName = "EAX-Minimal", + applicationId = "io.element.android.samples.minimal", + lowPrivacyLoggingEnabled = false, + versionName = "0.1.0", + versionCode = 1, + gitRevision = "TODO", // BuildConfig.GIT_REVISION, + gitRevisionDate = "TODO", // BuildConfig.GIT_REVISION_DATE, + gitBranchName = "TODO", // BuildConfig.GIT_BRANCH_NAME, + flavorDescription = "TODO", // BuildConfig.FLAVOR_DESCRIPTION, + flavorShortDescription = "TODO", // BuildConfig.SHORT_FLAVOR_DESCRIPTION, + ) + init { - Timber.plant(Timber.DebugTree()) - setupTracing(TracingConfigurations.debug) + val tracingConfiguration = TracingConfiguration( + filterConfiguration = TracingFilterConfigurations.debug, + writesToLogcat = true, + writesToFilesConfiguration = WriteToFilesConfiguration.Disabled + ) + RustTracingService(buildMeta).setupTracing(tracingConfiguration) } val appScope = MainScope() + CoroutineName("Minimal Scope") diff --git a/settings.gradle.kts b/settings.gradle.kts index 408c9e2934..751c65d388 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,10 @@ dependencyResolutionManagement { includeModule("com.github.matrix-org", "matrix-analytics-events") } } + // To have immediate access to Rust SDK versions + maven { + url = URI("https://s01.oss.sonatype.org/content/repositories/releases") + } flatDir { dirs("libraries/matrix/libs") } diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts index d7c17c7895..184bbc418a 100644 --- a/tests/testutils/build.gradle.kts +++ b/tests/testutils/build.gradle.kts @@ -30,4 +30,5 @@ dependencies { implementation(libs.test.junit) implementation(libs.coroutines.test) implementation(projects.libraries.core) + implementation(libs.test.turbine) } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt new file mode 100644 index 0000000000..06b6b3d3ea --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/ReceiveTurbine.kt @@ -0,0 +1,60 @@ +/* + * 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.tests.testutils + +import app.cash.turbine.Event +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.withTurbineTimeout +import io.element.android.libraries.core.data.tryOrNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Consume all items until timeout is reached waiting for an event or we receive terminal event. + * The timeout is applied for each event. + * @return the list of consumed items. + */ +suspend funReceiveTurbine .consumeItemsUntilTimeout(timeout: Duration = 100.milliseconds): List { + return consumeItemsUntilPredicate(timeout) { false } +} + +/** + * Consume items until predicate is true, or timeout is reached waiting for an event, or we receive terminal event. + * The timeout is applied for each event. + * @return the list of consumed items. + */ +suspend fun ReceiveTurbine .consumeItemsUntilPredicate( + timeout: Duration = 100.milliseconds, + predicate: (T) -> Boolean, +): List